diff --git a/drizzle/0041_plan_consolidation.sql b/drizzle/0041_plan_consolidation.sql new file mode 100644 index 0000000..3c2054f --- /dev/null +++ b/drizzle/0041_plan_consolidation.sql @@ -0,0 +1,28 @@ +-- ============================================================================ +-- 0041 — Plan consolidation window & plan-correctness (audit Phase 2) +-- +-- Purely ADDITIVE (CREATE/ALTER only — no DROP): the existing plan tables keep +-- their columns and legacy rows stay valid via defaults. Backs the pre-exam +-- consolidation window, week overrides, plan staleness, persisted scheduler +-- warnings and the idempotent exam-event sync: +-- * plan_session.kind — study | review | cards (majority category), +-- DEFAULT 'study' → existing sessions read as +-- study. +-- * semester_plan.week_overrides — {"2026-W31": 4} per-ISO-week hour caps. +-- * semester_plan.stale_at — null = fresh; set on plan-relevant edits. +-- * semester_plan.last_warnings — last recompute's scheduler warnings. +-- * event.goal_id (FK module_goal, ON DELETE CASCADE) + index — lets the exam +-- goal own an all-day calendar event keyed by +-- goal, so the scheduler blocks the exam day. +-- GoalConfig.reviewDays is a jsonb-only field (no DDL). +-- +-- Not runnable here (no DB in this environment); verified via db:generate (no +-- drift), typecheck and tests. +-- ============================================================================ +ALTER TABLE "event" ADD COLUMN "goal_id" text;--> statement-breakpoint +ALTER TABLE "semester_plan" ADD COLUMN "week_overrides" jsonb;--> statement-breakpoint +ALTER TABLE "semester_plan" ADD COLUMN "stale_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "semester_plan" ADD COLUMN "last_warnings" jsonb;--> statement-breakpoint +ALTER TABLE "plan_session" ADD COLUMN "kind" text DEFAULT 'study' NOT NULL;--> statement-breakpoint +ALTER TABLE "event" ADD CONSTRAINT "event_goal_id_module_goal_id_fk" FOREIGN KEY ("goal_id") REFERENCES "public"."module_goal"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "event_goalId_idx" ON "event" USING btree ("goal_id"); diff --git a/drizzle/0042_search_and_locale.sql b/drizzle/0042_search_and_locale.sql new file mode 100644 index 0000000..32f7858 --- /dev/null +++ b/drizzle/0042_search_and_locale.sql @@ -0,0 +1,23 @@ +-- ============================================================================ +-- 0042 — Bounded search & reminder locale (audit Phase 5) +-- +-- Purely ADDITIVE (CREATE/ALTER only — no DROP): +-- * material.text_content_tsv — STORED generated tsvector over text_content +-- ('german' config, coalesced for the nullable +-- column) + GIN index. Replaces the ILIKE +-- seq-scan in the command-palette search route +-- with websearch_to_tsquery('german', …) @@. +-- Mirrors material_chunk.content_tsv (0033). +-- * user_prefs.locale — preferred locale; reminder job localizes +-- push/email through it (R4 seam). +-- * event.skip_dates jsonb — ISO dates of individually-deleted recurring +-- occurrences; expandOccurrences skips them +-- (E18 single-occurrence delete, no series drop). +-- +-- Not runnable here (no DB in this environment); verified via db:generate (no +-- drift), typecheck and tests. +-- ============================================================================ +ALTER TABLE "event" ADD COLUMN "skip_dates" jsonb;--> statement-breakpoint +ALTER TABLE "user_prefs" ADD COLUMN "locale" text;--> statement-breakpoint +ALTER TABLE "material" ADD COLUMN "text_content_tsv" "tsvector" GENERATED ALWAYS AS (to_tsvector('german', coalesce("material"."text_content", ''))) STORED;--> statement-breakpoint +CREATE INDEX "material_text_content_tsv_idx" ON "material" USING gin ("text_content_tsv"); \ No newline at end of file diff --git a/drizzle/meta/0041_snapshot.json b/drizzle/meta/0041_snapshot.json new file mode 100644 index 0000000..9220f26 --- /dev/null +++ b/drizzle/meta/0041_snapshot.json @@ -0,0 +1,6025 @@ +{ + "id": "efc246bf-8a96-4f31-b6b0-6690e2d90caf", + "prevId": "4b767475-c8f6-445e-bd50-2d9b81804af2", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.ai_conversation": { + "name": "ai_conversation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "material_id": { + "name": "material_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'New conversation'" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_conversation_userId_idx": { + "name": "ai_conversation_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_conversation_user_id_user_id_fk": { + "name": "ai_conversation_user_id_user_id_fk", + "tableFrom": "ai_conversation", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_conversation_module_id_module_id_fk": { + "name": "ai_conversation_module_id_module_id_fk", + "tableFrom": "ai_conversation", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ai_conversation_material_id_material_id_fk": { + "name": "ai_conversation_material_id_material_id_fk", + "tableFrom": "ai_conversation", + "tableTo": "material", + "columnsFrom": [ + "material_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_message": { + "name": "ai_message", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parts": { + "name": "parts", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_message_conversationId_idx": { + "name": "ai_message_conversationId_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_message_conversation_id_ai_conversation_id_fk": { + "name": "ai_message_conversation_id_ai_conversation_id_fk", + "tableFrom": "ai_message", + "tableTo": "ai_conversation", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_usage_log": { + "name": "ai_usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'chat'" + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_usage_userId_idx": { + "name": "ai_usage_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_usage_createdAt_idx": { + "name": "ai_usage_createdAt_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_usage_log_user_id_user_id_fk": { + "name": "ai_usage_log_user_id_user_id_fk", + "tableFrom": "ai_usage_log", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_ai_key": { + "name": "user_ai_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_ai_key_user_id_user_id_fk": { + "name": "user_ai_key_user_id_user_id_fk", + "tableFrom": "user_ai_key", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_config": { + "name": "app_config", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assignment": { + "name": "assignment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'graded'" + }, + "ai_generated": { + "name": "ai_generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "points_achieved": { + "name": "points_achieved", + "type": "numeric(7, 2)", + "primaryKey": false, + "notNull": false + }, + "points_max": { + "name": "points_max", + "type": "numeric(7, 2)", + "primaryKey": false, + "notNull": false + }, + "subtasks": { + "name": "subtasks", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assignment_module_idx": { + "name": "assignment_module_idx", + "columns": [ + { + "expression": "module_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assignment_user_idx": { + "name": "assignment_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assignment_user_id_user_id_fk": { + "name": "assignment_user_id_user_id_fk", + "tableFrom": "assignment", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assignment_module_id_module_id_fk": { + "name": "assignment_module_id_module_id_fk", + "tableFrom": "assignment", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assignment_goal_id_module_goal_id_fk": { + "name": "assignment_goal_id_module_goal_id_fk", + "tableFrom": "assignment", + "tableTo": "module_goal", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assignment_material": { + "name": "assignment_material", + "schema": "", + "columns": { + "assignment_id": { + "name": "assignment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "material_id": { + "name": "material_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "assignment_material_assignment_id_assignment_id_fk": { + "name": "assignment_material_assignment_id_assignment_id_fk", + "tableFrom": "assignment_material", + "tableTo": "assignment", + "columnsFrom": [ + "assignment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assignment_material_material_id_material_id_fk": { + "name": "assignment_material_material_id_material_id_fk", + "tableFrom": "assignment_material", + "tableTo": "material", + "columnsFrom": [ + "material_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "assignment_material_assignment_id_material_id_pk": { + "name": "assignment_material_assignment_id_material_id_pk", + "columns": [ + "assignment_id", + "material_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor": { + "name": "actor", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_label": { + "name": "entity_label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "before": { + "name": "before", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after": { + "name": "after", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "undone": { + "name": "undone", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_user_created_idx": { + "name": "audit_log_user_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_user_id_user_id_fk": { + "name": "audit_log_user_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "passkey_userId_idx": { + "name": "passkey_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_credentialID_idx": { + "name": "passkey_credentialID_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.two_factor": { + "name": "two_factor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backup_codes": { + "name": "backup_codes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "failed_verification_count": { + "name": "failed_verification_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "locked_until": { + "name": "locked_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "twoFactor_secret_idx": { + "name": "twoFactor_secret_idx", + "columns": [ + { + "expression": "secret", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "twoFactor_userId_idx": { + "name": "twoFactor_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "two_factor_user_id_user_id_fk": { + "name": "two_factor_user_id_user_id_fk", + "tableFrom": "two_factor", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.generation_coverage": { + "name": "generation_coverage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "topic_id": { + "name": "topic_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "produced_count": { + "name": "produced_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "generation_coverage_target_topic_idx": { + "name": "generation_coverage_target_topic_idx", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "topic_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_coverage_job_idx": { + "name": "generation_coverage_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "generation_coverage_topic_id_outline_topic_id_fk": { + "name": "generation_coverage_topic_id_outline_topic_id_fk", + "tableFrom": "generation_coverage", + "tableTo": "outline_topic", + "columnsFrom": [ + "topic_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_coverage_job_id_generation_job_id_fk": { + "name": "generation_coverage_job_id_generation_job_id_fk", + "tableFrom": "generation_coverage", + "tableTo": "generation_job", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.generation_job": { + "name": "generation_job", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "outline_version": { + "name": "outline_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "topics_total": { + "name": "topics_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "topics_done": { + "name": "topics_done", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "produced_count": { + "name": "produced_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "batch_ref": { + "name": "batch_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "batch_model": { + "name": "batch_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "generation_job_user_idx": { + "name": "generation_job_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_job_target_idx": { + "name": "generation_job_target_idx", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "generation_job_user_id_user_id_fk": { + "name": "generation_job_user_id_user_id_fk", + "tableFrom": "generation_job", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_job_module_id_module_id_fk": { + "name": "generation_job_module_id_module_id_fk", + "tableFrom": "generation_job", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.degree_program": { + "name": "degree_program", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "degree_type": { + "name": "degree_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "institution": { + "name": "institution", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_ects": { + "name": "target_ects", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "grading_system": { + "name": "grading_system", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'german'" + }, + "grade_goal": { + "name": "grade_goal", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grade_scale": { + "name": "grade_scale", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "thesis_max_attempts": { + "name": "thesis_max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "degree_program_userId_idx": { + "name": "degree_program_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "degree_program_user_id_user_id_fk": { + "name": "degree_program_user_id_user_id_fk", + "tableFrom": "degree_program", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.external_resource": { + "name": "external_resource", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "program_id": { + "name": "program_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'website'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_note": { + "name": "encrypted_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "external_resource_userId_idx": { + "name": "external_resource_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "external_resource_moduleId_idx": { + "name": "external_resource_moduleId_idx", + "columns": [ + { + "expression": "module_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "external_resource_user_id_user_id_fk": { + "name": "external_resource_user_id_user_id_fk", + "tableFrom": "external_resource", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_resource_program_id_degree_program_id_fk": { + "name": "external_resource_program_id_degree_program_id_fk", + "tableFrom": "external_resource", + "tableTo": "degree_program", + "columnsFrom": [ + "program_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_resource_module_id_module_id_fk": { + "name": "external_resource_module_id_module_id_fk", + "tableFrom": "external_resource", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.goal_attempt": { + "name": "goal_attempt", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "result_percent": { + "name": "result_percent", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "passed": { + "name": "passed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "goal_attempt_goalId_idx": { + "name": "goal_attempt_goalId_idx", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "goal_attempt_goal_id_module_goal_id_fk": { + "name": "goal_attempt_goal_id_module_goal_id_fk", + "tableFrom": "goal_attempt", + "tableTo": "module_goal", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.grade": { + "name": "grade", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true + }, + "weight": { + "name": "weight", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true, + "default": "'1'" + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "graded_at": { + "name": "graded_at", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "grade_moduleId_idx": { + "name": "grade_moduleId_idx", + "columns": [ + { + "expression": "module_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "grade_module_id_module_id_fk": { + "name": "grade_module_id_module_id_fk", + "tableFrom": "grade", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.module_contact": { + "name": "module_contact", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "module_contact_moduleId_idx": { + "name": "module_contact_moduleId_idx", + "columns": [ + { + "expression": "module_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "module_contact_module_id_module_id_fk": { + "name": "module_contact_module_id_module_id_fk", + "tableFrom": "module_contact", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.module_goal": { + "name": "module_goal", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grading_role": { + "name": "grading_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'grade'" + }, + "weight": { + "name": "weight", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true, + "default": "'1'" + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "pass_fail": { + "name": "pass_fail", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "due_date": { + "name": "due_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "module_goal_moduleId_idx": { + "name": "module_goal_moduleId_idx", + "columns": [ + { + "expression": "module_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "module_goal_module_id_module_id_fk": { + "name": "module_goal_module_id_module_id_fk", + "tableFrom": "module_goal", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.semester": { + "name": "semester", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "program_id": { + "name": "program_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "end_date": { + "name": "end_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "semester_programId_idx": { + "name": "semester_programId_idx", + "columns": [ + { + "expression": "program_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "semester_program_id_degree_program_id_fk": { + "name": "semester_program_id_degree_program_id_fk", + "tableFrom": "semester", + "tableTo": "degree_program", + "columnsFrom": [ + "program_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.event": { + "name": "event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "goal_id": { + "name": "goal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'other'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "all_day": { + "name": "all_day", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ai_generated": { + "name": "ai_generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "reminder_offsets": { + "name": "reminder_offsets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "recurrence": { + "name": "recurrence", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "recurrence_until": { + "name": "recurrence_until", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "recurrence_weekdays": { + "name": "recurrence_weekdays", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "recurrence_interval": { + "name": "recurrence_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "event_userId_idx": { + "name": "event_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "event_startsAt_idx": { + "name": "event_startsAt_idx", + "columns": [ + { + "expression": "starts_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "event_goalId_idx": { + "name": "event_goalId_idx", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "event_user_id_user_id_fk": { + "name": "event_user_id_user_id_fk", + "tableFrom": "event", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_module_id_module_id_fk": { + "name": "event_module_id_module_id_fk", + "tableFrom": "event", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "event_goal_id_module_goal_id_fk": { + "name": "event_goal_id_module_goal_id_fk", + "tableFrom": "event", + "tableTo": "module_goal", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.module": { + "name": "module", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "semester_id": { + "name": "semester_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ects": { + "name": "ects", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "instructor": { + "name": "instructor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'planned'" + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_overrides": { + "name": "tool_overrides", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "module_semesterId_idx": { + "name": "module_semesterId_idx", + "columns": [ + { + "expression": "semester_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "module_semester_id_semester_id_fk": { + "name": "module_semester_id_semester_id_fk", + "tableFrom": "module", + "tableTo": "semester", + "columnsFrom": [ + "semester_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_prefs": { + "name": "user_prefs", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "ics_token": { + "name": "ics_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_program_id": { + "name": "active_program_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_semester_id": { + "name": "active_semester_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preferred_model": { + "name": "preferred_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "weekly_goal_minutes": { + "name": "weekly_goal_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_prefs_user_id_user_id_fk": { + "name": "user_prefs_user_id_user_id_fk", + "tableFrom": "user_prefs", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_prefs_active_program_id_degree_program_id_fk": { + "name": "user_prefs_active_program_id_degree_program_id_fk", + "tableFrom": "user_prefs", + "tableTo": "degree_program", + "columnsFrom": [ + "active_program_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_prefs_active_semester_id_semester_id_fk": { + "name": "user_prefs_active_semester_id_semester_id_fk", + "tableFrom": "user_prefs", + "tableTo": "semester", + "columnsFrom": [ + "active_semester_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_prefs_ics_token_unique": { + "name": "user_prefs_ics_token_unique", + "nullsNotDistinct": false, + "columns": [ + "ics_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.material": { + "name": "material", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text_content": { + "name": "text_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text_storage_path": { + "name": "text_storage_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "char_count": { + "name": "char_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "extraction_status": { + "name": "extraction_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "extraction_error": { + "name": "extraction_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "chunks_total": { + "name": "chunks_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "chunks_embedded": { + "name": "chunks_embedded", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "material_userId_idx": { + "name": "material_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "material_moduleId_idx": { + "name": "material_moduleId_idx", + "columns": [ + { + "expression": "module_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "material_folderId_idx": { + "name": "material_folderId_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "material_contentHash_idx": { + "name": "material_contentHash_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "content_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "material_user_id_user_id_fk": { + "name": "material_user_id_user_id_fk", + "tableFrom": "material", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "material_module_id_module_id_fk": { + "name": "material_module_id_module_id_fk", + "tableFrom": "material", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "material_folder_id_material_folder_id_fk": { + "name": "material_folder_id_material_folder_id_fk", + "tableFrom": "material", + "tableTo": "material_folder", + "columnsFrom": [ + "folder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.material_annotation": { + "name": "material_annotation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "material_id": { + "name": "material_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page": { + "name": "page", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "rect": { + "name": "rect", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'yellow'" + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "material_annotation_material_idx": { + "name": "material_annotation_material_idx", + "columns": [ + { + "expression": "material_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "material_annotation_material_id_material_id_fk": { + "name": "material_annotation_material_id_material_id_fk", + "tableFrom": "material_annotation", + "tableTo": "material", + "columnsFrom": [ + "material_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "material_annotation_user_id_user_id_fk": { + "name": "material_annotation_user_id_user_id_fk", + "tableFrom": "material_annotation", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.material_folder": { + "name": "material_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "material_folder_userId_idx": { + "name": "material_folder_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "material_folder_moduleId_idx": { + "name": "material_folder_moduleId_idx", + "columns": [ + { + "expression": "module_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "material_folder_parentId_idx": { + "name": "material_folder_parentId_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "material_folder_sibling_uniq": { + "name": "material_folder_sibling_uniq", + "columns": [ + { + "expression": "module_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "COALESCE(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "material_folder_user_id_user_id_fk": { + "name": "material_folder_user_id_user_id_fk", + "tableFrom": "material_folder", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "material_folder_module_id_module_id_fk": { + "name": "material_folder_module_id_module_id_fk", + "tableFrom": "material_folder", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "material_folder_parent_id_material_folder_id_fk": { + "name": "material_folder_parent_id_material_folder_id_fk", + "tableFrom": "material_folder", + "tableTo": "material_folder", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.material_chunk": { + "name": "material_chunk", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "material_id": { + "name": "material_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "parent_chunk_id": { + "name": "parent_chunk_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contextual_header": { + "name": "contextual_header", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('german', \"material_chunk\".\"content\")", + "type": "stored" + } + } + }, + "indexes": { + "material_chunk_materialId_idx": { + "name": "material_chunk_materialId_idx", + "columns": [ + { + "expression": "material_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "material_chunk_material_level_idx": { + "name": "material_chunk_material_level_idx", + "columns": [ + { + "expression": "material_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "material_chunk_tsv_idx": { + "name": "material_chunk_tsv_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "material_chunk_material_id_material_id_fk": { + "name": "material_chunk_material_id_material_id_fk", + "tableFrom": "material_chunk", + "tableTo": "material", + "columnsFrom": [ + "material_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "material_chunk_parent_chunk_id_material_chunk_id_fk": { + "name": "material_chunk_parent_chunk_id_material_chunk_id_fk", + "tableFrom": "material_chunk", + "tableTo": "material_chunk", + "columnsFrom": [ + "parent_chunk_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.answer_log": { + "name": "answer_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "attempt_id": { + "name": "attempt_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "question_id": { + "name": "question_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "answer": { + "name": "answer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correct": { + "name": "correct", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "answer_log_attemptId_idx": { + "name": "answer_log_attemptId_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "answer_log_attempt_id_quiz_attempt_id_fk": { + "name": "answer_log_attempt_id_quiz_attempt_id_fk", + "tableFrom": "answer_log", + "tableTo": "quiz_attempt", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "answer_log_question_id_question_id_fk": { + "name": "answer_log_question_id_question_id_fk", + "tableFrom": "answer_log", + "tableTo": "question", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deck": { + "name": "deck", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_generated": { + "name": "ai_generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'normal'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deck_userId_idx": { + "name": "deck_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deck_user_id_user_id_fk": { + "name": "deck_user_id_user_id_fk", + "tableFrom": "deck", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deck_module_id_module_id_fk": { + "name": "deck_module_id_module_id_fk", + "tableFrom": "deck", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.flashcard": { + "name": "flashcard", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "deck_id": { + "name": "deck_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "front": { + "name": "front", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "back": { + "name": "back", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "due": { + "name": "due", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stability": { + "name": "stability", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "difficulty": { + "name": "difficulty", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "elapsed_days": { + "name": "elapsed_days", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "scheduled_days": { + "name": "scheduled_days", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "learning_steps": { + "name": "learning_steps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reps": { + "name": "reps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lapses": { + "name": "lapses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "state": { + "name": "state", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_review": { + "name": "last_review", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "flashcard_deckId_idx": { + "name": "flashcard_deckId_idx", + "columns": [ + { + "expression": "deck_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "flashcard_due_idx": { + "name": "flashcard_due_idx", + "columns": [ + { + "expression": "due", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "flashcard_deck_id_deck_id_fk": { + "name": "flashcard_deck_id_deck_id_fk", + "tableFrom": "flashcard", + "tableTo": "deck", + "columnsFrom": [ + "deck_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.question": { + "name": "question", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "quiz_id": { + "name": "quiz_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "correct_index": { + "name": "correct_index", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "reference_answer": { + "name": "reference_answer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "explanation": { + "name": "explanation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "question_quizId_idx": { + "name": "question_quizId_idx", + "columns": [ + { + "expression": "quiz_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "question_quiz_id_quiz_id_fk": { + "name": "question_quiz_id_quiz_id_fk", + "tableFrom": "question", + "tableTo": "quiz", + "columnsFrom": [ + "quiz_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz": { + "name": "quiz", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_generated": { + "name": "ai_generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "quiz_userId_idx": { + "name": "quiz_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_user_id_user_id_fk": { + "name": "quiz_user_id_user_id_fk", + "tableFrom": "quiz", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "quiz_module_id_module_id_fk": { + "name": "quiz_module_id_module_id_fk", + "tableFrom": "quiz", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_attempt": { + "name": "quiz_attempt", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "quiz_id": { + "name": "quiz_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "quiz_attempt_quizId_idx": { + "name": "quiz_attempt_quizId_idx", + "columns": [ + { + "expression": "quiz_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "quiz_attempt_userId_idx": { + "name": "quiz_attempt_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_attempt_quiz_id_quiz_id_fk": { + "name": "quiz_attempt_quiz_id_quiz_id_fk", + "tableFrom": "quiz_attempt", + "tableTo": "quiz", + "columnsFrom": [ + "quiz_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "quiz_attempt_user_id_user_id_fk": { + "name": "quiz_attempt_user_id_user_id_fk", + "tableFrom": "quiz_attempt", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_log": { + "name": "review_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_log_cardId_idx": { + "name": "review_log_cardId_idx", + "columns": [ + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_log_userId_idx": { + "name": "review_log_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_log_card_id_flashcard_id_fk": { + "name": "review_log_card_id_flashcard_id_fk", + "tableFrom": "review_log", + "tableTo": "flashcard", + "columnsFrom": [ + "card_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_log_user_id_user_id_fk": { + "name": "review_log_user_id_user_id_fk", + "tableFrom": "review_log", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.writing_milestone": { + "name": "writing_milestone", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "done": { + "name": "done", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "writing_milestone_projectId_idx": { + "name": "writing_milestone_projectId_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "writing_milestone_project_id_writing_project_id_fk": { + "name": "writing_milestone_project_id_writing_project_id_fk", + "tableFrom": "writing_milestone", + "tableTo": "writing_project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.writing_project": { + "name": "writing_project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "program_id": { + "name": "program_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "semester_id": { + "name": "semester_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'thesis'" + }, + "goal_id": { + "name": "goal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thesis_type": { + "name": "thesis_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "variant": { + "name": "variant", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'scientific'" + }, + "task_description": { + "name": "task_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'topic'" + }, + "research_question": { + "name": "research_question", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outline": { + "name": "outline", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "superseded_by_id": { + "name": "superseded_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "writing_project_userId_idx": { + "name": "writing_project_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "thesis_active_per_program_uq": { + "name": "thesis_active_per_program_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "program_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"writing_project\".\"superseded_by_id\" is null and \"writing_project\".\"program_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "writing_active_per_goal_uq": { + "name": "writing_active_per_goal_uq", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"writing_project\".\"kind\" = 'term_paper' and \"writing_project\".\"superseded_by_id\" is null and \"writing_project\".\"goal_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "writing_project_user_id_user_id_fk": { + "name": "writing_project_user_id_user_id_fk", + "tableFrom": "writing_project", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "writing_project_program_id_degree_program_id_fk": { + "name": "writing_project_program_id_degree_program_id_fk", + "tableFrom": "writing_project", + "tableTo": "degree_program", + "columnsFrom": [ + "program_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "writing_project_semester_id_semester_id_fk": { + "name": "writing_project_semester_id_semester_id_fk", + "tableFrom": "writing_project", + "tableTo": "semester", + "columnsFrom": [ + "semester_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "writing_project_goal_id_module_goal_id_fk": { + "name": "writing_project_goal_id_module_goal_id_fk", + "tableFrom": "writing_project", + "tableTo": "module_goal", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "writing_project_superseded_by_id_writing_project_id_fk": { + "name": "writing_project_superseded_by_id_writing_project_id_fk", + "tableFrom": "writing_project", + "tableTo": "writing_project", + "columnsFrom": [ + "superseded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_prefs": { + "name": "notification_prefs", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email_reminders": { + "name": "email_reminders", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "push_reminders": { + "name": "push_reminders", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "channels": { + "name": "channels", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "notification_prefs_user_id_user_id_fk": { + "name": "notification_prefs_user_id_user_id_fk", + "tableFrom": "notification_prefs", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_sent": { + "name": "notification_sent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "notification_sent_user_id_user_id_fk": { + "name": "notification_sent_user_id_user_id_fk", + "tableFrom": "notification_sent", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notification_sent_user_key": { + "name": "notification_sent_user_key", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_subscription": { + "name": "push_subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "p256dh": { + "name": "p256dh", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "push_subscription_userId_idx": { + "name": "push_subscription_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_subscription_user_id_user_id_fk": { + "name": "push_subscription_user_id_user_id_fk", + "tableFrom": "push_subscription", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "push_subscription_endpoint_unique": { + "name": "push_subscription_endpoint_unique", + "nullsNotDistinct": false, + "columns": [ + "endpoint" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reminder_sent": { + "name": "reminder_sent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "offset_minutes": { + "name": "offset_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "occurrence_date": { + "name": "occurrence_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "reminder_sent_event_id_event_id_fk": { + "name": "reminder_sent_event_id_event_id_fk", + "tableFrom": "reminder_sent", + "tableTo": "event", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "reminder_sent_event_offset_occurrence": { + "name": "reminder_sent_event_offset_occurrence", + "nullsNotDistinct": false, + "columns": [ + "event_id", + "offset_minutes", + "occurrence_date" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invite": { + "name": "invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "max_uses": { + "name": "max_uses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "used_count": { + "name": "used_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invite_created_by_user_id_fk": { + "name": "invite_created_by_user_id_fk", + "tableFrom": "invite", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invite_token_unique": { + "name": "invite_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.study_session": { + "name": "study_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pomodoro'" + }, + "duration_minutes": { + "name": "duration_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "study_session_userId_idx": { + "name": "study_session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "study_session_startedAt_idx": { + "name": "study_session_startedAt_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "study_session_user_id_user_id_fk": { + "name": "study_session_user_id_user_id_fk", + "tableFrom": "study_session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "study_session_module_id_module_id_fk": { + "name": "study_session_module_id_module_id_fk", + "tableFrom": "study_session", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.semester_plan": { + "name": "semester_plan", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "semester_id": { + "name": "semester_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "availability": { + "name": "availability", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "week_overrides": { + "name": "week_overrides", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "stale_at": { + "name": "stale_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_warnings": { + "name": "last_warnings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "generated_at": { + "name": "generated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "semester_plan_user_idx": { + "name": "semester_plan_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "semester_plan_user_id_user_id_fk": { + "name": "semester_plan_user_id_user_id_fk", + "tableFrom": "semester_plan", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "semester_plan_semester_id_semester_id_fk": { + "name": "semester_plan_semester_id_semester_id_fk", + "tableFrom": "semester_plan", + "tableTo": "semester", + "columnsFrom": [ + "semester_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "semester_plan_semester_id_unique": { + "name": "semester_plan_semester_id_unique", + "nullsNotDistinct": false, + "columns": [ + "semester_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.module_plan": { + "name": "module_plan", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weight": { + "name": "weight", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true, + "default": "'1'" + }, + "weekly_hours_target": { + "name": "weekly_hours_target", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "preferred_weekdays": { + "name": "preferred_weekdays", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "module_plan_module_idx": { + "name": "module_plan_module_idx", + "columns": [ + { + "expression": "module_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "module_plan_module_id_module_id_fk": { + "name": "module_plan_module_id_module_id_fk", + "tableFrom": "module_plan", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "module_plan_module_id_unique": { + "name": "module_plan_module_id_unique", + "nullsNotDistinct": false, + "columns": [ + "module_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plan_session": { + "name": "plan_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "semester_plan_id": { + "name": "semester_plan_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "start_time": { + "name": "start_time", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration_minutes": { + "name": "duration_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'study'" + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "done": { + "name": "done", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plan_session_plan_date_idx": { + "name": "plan_session_plan_date_idx", + "columns": [ + { + "expression": "semester_plan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plan_session_semester_plan_id_semester_plan_id_fk": { + "name": "plan_session_semester_plan_id_semester_plan_id_fk", + "tableFrom": "plan_session", + "tableTo": "semester_plan", + "columnsFrom": [ + "semester_plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plan_session_module_id_module_id_fk": { + "name": "plan_session_module_id_module_id_fk", + "tableFrom": "plan_session", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plan_task": { + "name": "plan_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "estimated_minutes": { + "name": "estimated_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60 + }, + "done": { + "name": "done", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "due_date": { + "name": "due_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"kind\":\"manual\"}'::jsonb" + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "ai_generated": { + "name": "ai_generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plan_task_module_idx": { + "name": "plan_task_module_idx", + "columns": [ + { + "expression": "module_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plan_task_session_idx": { + "name": "plan_task_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plan_task_module_id_module_id_fk": { + "name": "plan_task_module_id_module_id_fk", + "tableFrom": "plan_task", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plan_task_goal_id_module_goal_id_fk": { + "name": "plan_task_goal_id_module_goal_id_fk", + "tableFrom": "plan_task", + "tableTo": "module_goal", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "plan_task_session_id_plan_session_id_fk": { + "name": "plan_task_session_id_plan_session_id_fk", + "tableFrom": "plan_task", + "tableTo": "plan_session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.module_outline": { + "name": "module_outline", + "schema": "", + "columns": { + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "topic_count": { + "name": "topic_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "module_outline_module_id_module_id_fk": { + "name": "module_outline_module_id_module_id_fk", + "tableFrom": "module_outline", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "module_outline_user_id_user_id_fk": { + "name": "module_outline_user_id_user_id_fk", + "tableFrom": "module_outline", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outline_topic": { + "name": "outline_topic", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title_key": { + "name": "title_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_material_ids": { + "name": "source_material_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "outline_topic_module_idx": { + "name": "outline_topic_module_idx", + "columns": [ + { + "expression": "module_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outline_topic_module_version_idx": { + "name": "outline_topic_module_version_idx", + "columns": [ + { + "expression": "module_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "outline_topic_module_id_module_id_fk": { + "name": "outline_topic_module_id_module_id_fk", + "tableFrom": "outline_topic", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "outline_topic_user_id_user_id_fk": { + "name": "outline_topic_user_id_user_id_fk", + "tableFrom": "outline_topic", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "outline_topic_parent_id_outline_topic_id_fk": { + "name": "outline_topic_parent_id_outline_topic_id_fk", + "tableFrom": "outline_topic", + "tableTo": "outline_topic", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0042_snapshot.json b/drizzle/meta/0042_snapshot.json new file mode 100644 index 0000000..ca6a9c2 --- /dev/null +++ b/drizzle/meta/0042_snapshot.json @@ -0,0 +1,6062 @@ +{ + "id": "11ffcb5f-e91b-48d6-a577-972b307777f4", + "prevId": "efc246bf-8a96-4f31-b6b0-6690e2d90caf", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.ai_conversation": { + "name": "ai_conversation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "material_id": { + "name": "material_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'New conversation'" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_conversation_userId_idx": { + "name": "ai_conversation_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_conversation_user_id_user_id_fk": { + "name": "ai_conversation_user_id_user_id_fk", + "tableFrom": "ai_conversation", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_conversation_module_id_module_id_fk": { + "name": "ai_conversation_module_id_module_id_fk", + "tableFrom": "ai_conversation", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ai_conversation_material_id_material_id_fk": { + "name": "ai_conversation_material_id_material_id_fk", + "tableFrom": "ai_conversation", + "tableTo": "material", + "columnsFrom": [ + "material_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_message": { + "name": "ai_message", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parts": { + "name": "parts", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_message_conversationId_idx": { + "name": "ai_message_conversationId_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_message_conversation_id_ai_conversation_id_fk": { + "name": "ai_message_conversation_id_ai_conversation_id_fk", + "tableFrom": "ai_message", + "tableTo": "ai_conversation", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_usage_log": { + "name": "ai_usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'chat'" + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_usage_userId_idx": { + "name": "ai_usage_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_usage_createdAt_idx": { + "name": "ai_usage_createdAt_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_usage_log_user_id_user_id_fk": { + "name": "ai_usage_log_user_id_user_id_fk", + "tableFrom": "ai_usage_log", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_ai_key": { + "name": "user_ai_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_ai_key_user_id_user_id_fk": { + "name": "user_ai_key_user_id_user_id_fk", + "tableFrom": "user_ai_key", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_config": { + "name": "app_config", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assignment": { + "name": "assignment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'graded'" + }, + "ai_generated": { + "name": "ai_generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "points_achieved": { + "name": "points_achieved", + "type": "numeric(7, 2)", + "primaryKey": false, + "notNull": false + }, + "points_max": { + "name": "points_max", + "type": "numeric(7, 2)", + "primaryKey": false, + "notNull": false + }, + "subtasks": { + "name": "subtasks", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assignment_module_idx": { + "name": "assignment_module_idx", + "columns": [ + { + "expression": "module_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assignment_user_idx": { + "name": "assignment_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assignment_user_id_user_id_fk": { + "name": "assignment_user_id_user_id_fk", + "tableFrom": "assignment", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assignment_module_id_module_id_fk": { + "name": "assignment_module_id_module_id_fk", + "tableFrom": "assignment", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assignment_goal_id_module_goal_id_fk": { + "name": "assignment_goal_id_module_goal_id_fk", + "tableFrom": "assignment", + "tableTo": "module_goal", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assignment_material": { + "name": "assignment_material", + "schema": "", + "columns": { + "assignment_id": { + "name": "assignment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "material_id": { + "name": "material_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "assignment_material_assignment_id_assignment_id_fk": { + "name": "assignment_material_assignment_id_assignment_id_fk", + "tableFrom": "assignment_material", + "tableTo": "assignment", + "columnsFrom": [ + "assignment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assignment_material_material_id_material_id_fk": { + "name": "assignment_material_material_id_material_id_fk", + "tableFrom": "assignment_material", + "tableTo": "material", + "columnsFrom": [ + "material_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "assignment_material_assignment_id_material_id_pk": { + "name": "assignment_material_assignment_id_material_id_pk", + "columns": [ + "assignment_id", + "material_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor": { + "name": "actor", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_label": { + "name": "entity_label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "before": { + "name": "before", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after": { + "name": "after", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "undone": { + "name": "undone", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_user_created_idx": { + "name": "audit_log_user_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_user_id_user_id_fk": { + "name": "audit_log_user_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "passkey_userId_idx": { + "name": "passkey_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_credentialID_idx": { + "name": "passkey_credentialID_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.two_factor": { + "name": "two_factor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backup_codes": { + "name": "backup_codes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "failed_verification_count": { + "name": "failed_verification_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "locked_until": { + "name": "locked_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "twoFactor_secret_idx": { + "name": "twoFactor_secret_idx", + "columns": [ + { + "expression": "secret", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "twoFactor_userId_idx": { + "name": "twoFactor_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "two_factor_user_id_user_id_fk": { + "name": "two_factor_user_id_user_id_fk", + "tableFrom": "two_factor", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.generation_coverage": { + "name": "generation_coverage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "topic_id": { + "name": "topic_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "produced_count": { + "name": "produced_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "generation_coverage_target_topic_idx": { + "name": "generation_coverage_target_topic_idx", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "topic_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_coverage_job_idx": { + "name": "generation_coverage_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "generation_coverage_topic_id_outline_topic_id_fk": { + "name": "generation_coverage_topic_id_outline_topic_id_fk", + "tableFrom": "generation_coverage", + "tableTo": "outline_topic", + "columnsFrom": [ + "topic_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_coverage_job_id_generation_job_id_fk": { + "name": "generation_coverage_job_id_generation_job_id_fk", + "tableFrom": "generation_coverage", + "tableTo": "generation_job", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.generation_job": { + "name": "generation_job", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "outline_version": { + "name": "outline_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "topics_total": { + "name": "topics_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "topics_done": { + "name": "topics_done", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "produced_count": { + "name": "produced_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "batch_ref": { + "name": "batch_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "batch_model": { + "name": "batch_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "generation_job_user_idx": { + "name": "generation_job_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_job_target_idx": { + "name": "generation_job_target_idx", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "generation_job_user_id_user_id_fk": { + "name": "generation_job_user_id_user_id_fk", + "tableFrom": "generation_job", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_job_module_id_module_id_fk": { + "name": "generation_job_module_id_module_id_fk", + "tableFrom": "generation_job", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.degree_program": { + "name": "degree_program", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "degree_type": { + "name": "degree_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "institution": { + "name": "institution", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_ects": { + "name": "target_ects", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "grading_system": { + "name": "grading_system", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'german'" + }, + "grade_goal": { + "name": "grade_goal", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grade_scale": { + "name": "grade_scale", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "thesis_max_attempts": { + "name": "thesis_max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "degree_program_userId_idx": { + "name": "degree_program_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "degree_program_user_id_user_id_fk": { + "name": "degree_program_user_id_user_id_fk", + "tableFrom": "degree_program", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.external_resource": { + "name": "external_resource", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "program_id": { + "name": "program_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'website'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_note": { + "name": "encrypted_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "external_resource_userId_idx": { + "name": "external_resource_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "external_resource_moduleId_idx": { + "name": "external_resource_moduleId_idx", + "columns": [ + { + "expression": "module_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "external_resource_user_id_user_id_fk": { + "name": "external_resource_user_id_user_id_fk", + "tableFrom": "external_resource", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_resource_program_id_degree_program_id_fk": { + "name": "external_resource_program_id_degree_program_id_fk", + "tableFrom": "external_resource", + "tableTo": "degree_program", + "columnsFrom": [ + "program_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_resource_module_id_module_id_fk": { + "name": "external_resource_module_id_module_id_fk", + "tableFrom": "external_resource", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.goal_attempt": { + "name": "goal_attempt", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "result_percent": { + "name": "result_percent", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "passed": { + "name": "passed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "goal_attempt_goalId_idx": { + "name": "goal_attempt_goalId_idx", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "goal_attempt_goal_id_module_goal_id_fk": { + "name": "goal_attempt_goal_id_module_goal_id_fk", + "tableFrom": "goal_attempt", + "tableTo": "module_goal", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.grade": { + "name": "grade", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true + }, + "weight": { + "name": "weight", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true, + "default": "'1'" + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "graded_at": { + "name": "graded_at", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "grade_moduleId_idx": { + "name": "grade_moduleId_idx", + "columns": [ + { + "expression": "module_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "grade_module_id_module_id_fk": { + "name": "grade_module_id_module_id_fk", + "tableFrom": "grade", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.module_contact": { + "name": "module_contact", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "module_contact_moduleId_idx": { + "name": "module_contact_moduleId_idx", + "columns": [ + { + "expression": "module_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "module_contact_module_id_module_id_fk": { + "name": "module_contact_module_id_module_id_fk", + "tableFrom": "module_contact", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.module_goal": { + "name": "module_goal", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grading_role": { + "name": "grading_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'grade'" + }, + "weight": { + "name": "weight", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true, + "default": "'1'" + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "pass_fail": { + "name": "pass_fail", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "due_date": { + "name": "due_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "module_goal_moduleId_idx": { + "name": "module_goal_moduleId_idx", + "columns": [ + { + "expression": "module_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "module_goal_module_id_module_id_fk": { + "name": "module_goal_module_id_module_id_fk", + "tableFrom": "module_goal", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.semester": { + "name": "semester", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "program_id": { + "name": "program_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "end_date": { + "name": "end_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "semester_programId_idx": { + "name": "semester_programId_idx", + "columns": [ + { + "expression": "program_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "semester_program_id_degree_program_id_fk": { + "name": "semester_program_id_degree_program_id_fk", + "tableFrom": "semester", + "tableTo": "degree_program", + "columnsFrom": [ + "program_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.event": { + "name": "event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "goal_id": { + "name": "goal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'other'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "all_day": { + "name": "all_day", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ai_generated": { + "name": "ai_generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "reminder_offsets": { + "name": "reminder_offsets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "recurrence": { + "name": "recurrence", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "recurrence_until": { + "name": "recurrence_until", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "recurrence_weekdays": { + "name": "recurrence_weekdays", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "recurrence_interval": { + "name": "recurrence_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "skip_dates": { + "name": "skip_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "event_userId_idx": { + "name": "event_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "event_startsAt_idx": { + "name": "event_startsAt_idx", + "columns": [ + { + "expression": "starts_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "event_goalId_idx": { + "name": "event_goalId_idx", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "event_user_id_user_id_fk": { + "name": "event_user_id_user_id_fk", + "tableFrom": "event", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_module_id_module_id_fk": { + "name": "event_module_id_module_id_fk", + "tableFrom": "event", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "event_goal_id_module_goal_id_fk": { + "name": "event_goal_id_module_goal_id_fk", + "tableFrom": "event", + "tableTo": "module_goal", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.module": { + "name": "module", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "semester_id": { + "name": "semester_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ects": { + "name": "ects", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "instructor": { + "name": "instructor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'planned'" + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_overrides": { + "name": "tool_overrides", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "module_semesterId_idx": { + "name": "module_semesterId_idx", + "columns": [ + { + "expression": "semester_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "module_semester_id_semester_id_fk": { + "name": "module_semester_id_semester_id_fk", + "tableFrom": "module", + "tableTo": "semester", + "columnsFrom": [ + "semester_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_prefs": { + "name": "user_prefs", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "ics_token": { + "name": "ics_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_program_id": { + "name": "active_program_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_semester_id": { + "name": "active_semester_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preferred_model": { + "name": "preferred_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "weekly_goal_minutes": { + "name": "weekly_goal_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_prefs_user_id_user_id_fk": { + "name": "user_prefs_user_id_user_id_fk", + "tableFrom": "user_prefs", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_prefs_active_program_id_degree_program_id_fk": { + "name": "user_prefs_active_program_id_degree_program_id_fk", + "tableFrom": "user_prefs", + "tableTo": "degree_program", + "columnsFrom": [ + "active_program_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_prefs_active_semester_id_semester_id_fk": { + "name": "user_prefs_active_semester_id_semester_id_fk", + "tableFrom": "user_prefs", + "tableTo": "semester", + "columnsFrom": [ + "active_semester_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_prefs_ics_token_unique": { + "name": "user_prefs_ics_token_unique", + "nullsNotDistinct": false, + "columns": [ + "ics_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.material": { + "name": "material", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text_content": { + "name": "text_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text_storage_path": { + "name": "text_storage_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "char_count": { + "name": "char_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "extraction_status": { + "name": "extraction_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "extraction_error": { + "name": "extraction_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "chunks_total": { + "name": "chunks_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "chunks_embedded": { + "name": "chunks_embedded", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "text_content_tsv": { + "name": "text_content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('german', coalesce(\"material\".\"text_content\", ''))", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "material_userId_idx": { + "name": "material_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "material_moduleId_idx": { + "name": "material_moduleId_idx", + "columns": [ + { + "expression": "module_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "material_folderId_idx": { + "name": "material_folderId_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "material_contentHash_idx": { + "name": "material_contentHash_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "content_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "material_text_content_tsv_idx": { + "name": "material_text_content_tsv_idx", + "columns": [ + { + "expression": "text_content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "material_user_id_user_id_fk": { + "name": "material_user_id_user_id_fk", + "tableFrom": "material", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "material_module_id_module_id_fk": { + "name": "material_module_id_module_id_fk", + "tableFrom": "material", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "material_folder_id_material_folder_id_fk": { + "name": "material_folder_id_material_folder_id_fk", + "tableFrom": "material", + "tableTo": "material_folder", + "columnsFrom": [ + "folder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.material_annotation": { + "name": "material_annotation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "material_id": { + "name": "material_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page": { + "name": "page", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "rect": { + "name": "rect", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'yellow'" + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "material_annotation_material_idx": { + "name": "material_annotation_material_idx", + "columns": [ + { + "expression": "material_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "material_annotation_material_id_material_id_fk": { + "name": "material_annotation_material_id_material_id_fk", + "tableFrom": "material_annotation", + "tableTo": "material", + "columnsFrom": [ + "material_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "material_annotation_user_id_user_id_fk": { + "name": "material_annotation_user_id_user_id_fk", + "tableFrom": "material_annotation", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.material_folder": { + "name": "material_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "material_folder_userId_idx": { + "name": "material_folder_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "material_folder_moduleId_idx": { + "name": "material_folder_moduleId_idx", + "columns": [ + { + "expression": "module_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "material_folder_parentId_idx": { + "name": "material_folder_parentId_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "material_folder_sibling_uniq": { + "name": "material_folder_sibling_uniq", + "columns": [ + { + "expression": "module_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "COALESCE(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "material_folder_user_id_user_id_fk": { + "name": "material_folder_user_id_user_id_fk", + "tableFrom": "material_folder", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "material_folder_module_id_module_id_fk": { + "name": "material_folder_module_id_module_id_fk", + "tableFrom": "material_folder", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "material_folder_parent_id_material_folder_id_fk": { + "name": "material_folder_parent_id_material_folder_id_fk", + "tableFrom": "material_folder", + "tableTo": "material_folder", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.material_chunk": { + "name": "material_chunk", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "material_id": { + "name": "material_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "parent_chunk_id": { + "name": "parent_chunk_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contextual_header": { + "name": "contextual_header", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('german', \"material_chunk\".\"content\")", + "type": "stored" + } + } + }, + "indexes": { + "material_chunk_materialId_idx": { + "name": "material_chunk_materialId_idx", + "columns": [ + { + "expression": "material_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "material_chunk_material_level_idx": { + "name": "material_chunk_material_level_idx", + "columns": [ + { + "expression": "material_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "material_chunk_tsv_idx": { + "name": "material_chunk_tsv_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "material_chunk_material_id_material_id_fk": { + "name": "material_chunk_material_id_material_id_fk", + "tableFrom": "material_chunk", + "tableTo": "material", + "columnsFrom": [ + "material_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "material_chunk_parent_chunk_id_material_chunk_id_fk": { + "name": "material_chunk_parent_chunk_id_material_chunk_id_fk", + "tableFrom": "material_chunk", + "tableTo": "material_chunk", + "columnsFrom": [ + "parent_chunk_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.answer_log": { + "name": "answer_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "attempt_id": { + "name": "attempt_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "question_id": { + "name": "question_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "answer": { + "name": "answer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correct": { + "name": "correct", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "answer_log_attemptId_idx": { + "name": "answer_log_attemptId_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "answer_log_attempt_id_quiz_attempt_id_fk": { + "name": "answer_log_attempt_id_quiz_attempt_id_fk", + "tableFrom": "answer_log", + "tableTo": "quiz_attempt", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "answer_log_question_id_question_id_fk": { + "name": "answer_log_question_id_question_id_fk", + "tableFrom": "answer_log", + "tableTo": "question", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deck": { + "name": "deck", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_generated": { + "name": "ai_generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'normal'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deck_userId_idx": { + "name": "deck_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deck_user_id_user_id_fk": { + "name": "deck_user_id_user_id_fk", + "tableFrom": "deck", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deck_module_id_module_id_fk": { + "name": "deck_module_id_module_id_fk", + "tableFrom": "deck", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.flashcard": { + "name": "flashcard", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "deck_id": { + "name": "deck_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "front": { + "name": "front", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "back": { + "name": "back", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "due": { + "name": "due", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stability": { + "name": "stability", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "difficulty": { + "name": "difficulty", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "elapsed_days": { + "name": "elapsed_days", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "scheduled_days": { + "name": "scheduled_days", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "learning_steps": { + "name": "learning_steps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reps": { + "name": "reps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lapses": { + "name": "lapses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "state": { + "name": "state", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_review": { + "name": "last_review", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "flashcard_deckId_idx": { + "name": "flashcard_deckId_idx", + "columns": [ + { + "expression": "deck_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "flashcard_due_idx": { + "name": "flashcard_due_idx", + "columns": [ + { + "expression": "due", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "flashcard_deck_id_deck_id_fk": { + "name": "flashcard_deck_id_deck_id_fk", + "tableFrom": "flashcard", + "tableTo": "deck", + "columnsFrom": [ + "deck_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.question": { + "name": "question", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "quiz_id": { + "name": "quiz_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "correct_index": { + "name": "correct_index", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "reference_answer": { + "name": "reference_answer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "explanation": { + "name": "explanation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "question_quizId_idx": { + "name": "question_quizId_idx", + "columns": [ + { + "expression": "quiz_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "question_quiz_id_quiz_id_fk": { + "name": "question_quiz_id_quiz_id_fk", + "tableFrom": "question", + "tableTo": "quiz", + "columnsFrom": [ + "quiz_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz": { + "name": "quiz", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_generated": { + "name": "ai_generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "quiz_userId_idx": { + "name": "quiz_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_user_id_user_id_fk": { + "name": "quiz_user_id_user_id_fk", + "tableFrom": "quiz", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "quiz_module_id_module_id_fk": { + "name": "quiz_module_id_module_id_fk", + "tableFrom": "quiz", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_attempt": { + "name": "quiz_attempt", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "quiz_id": { + "name": "quiz_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "quiz_attempt_quizId_idx": { + "name": "quiz_attempt_quizId_idx", + "columns": [ + { + "expression": "quiz_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "quiz_attempt_userId_idx": { + "name": "quiz_attempt_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_attempt_quiz_id_quiz_id_fk": { + "name": "quiz_attempt_quiz_id_quiz_id_fk", + "tableFrom": "quiz_attempt", + "tableTo": "quiz", + "columnsFrom": [ + "quiz_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "quiz_attempt_user_id_user_id_fk": { + "name": "quiz_attempt_user_id_user_id_fk", + "tableFrom": "quiz_attempt", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_log": { + "name": "review_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_log_cardId_idx": { + "name": "review_log_cardId_idx", + "columns": [ + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_log_userId_idx": { + "name": "review_log_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_log_card_id_flashcard_id_fk": { + "name": "review_log_card_id_flashcard_id_fk", + "tableFrom": "review_log", + "tableTo": "flashcard", + "columnsFrom": [ + "card_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_log_user_id_user_id_fk": { + "name": "review_log_user_id_user_id_fk", + "tableFrom": "review_log", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.writing_milestone": { + "name": "writing_milestone", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "done": { + "name": "done", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "writing_milestone_projectId_idx": { + "name": "writing_milestone_projectId_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "writing_milestone_project_id_writing_project_id_fk": { + "name": "writing_milestone_project_id_writing_project_id_fk", + "tableFrom": "writing_milestone", + "tableTo": "writing_project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.writing_project": { + "name": "writing_project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "program_id": { + "name": "program_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "semester_id": { + "name": "semester_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'thesis'" + }, + "goal_id": { + "name": "goal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thesis_type": { + "name": "thesis_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "variant": { + "name": "variant", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'scientific'" + }, + "task_description": { + "name": "task_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'topic'" + }, + "research_question": { + "name": "research_question", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outline": { + "name": "outline", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "superseded_by_id": { + "name": "superseded_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "writing_project_userId_idx": { + "name": "writing_project_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "thesis_active_per_program_uq": { + "name": "thesis_active_per_program_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "program_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"writing_project\".\"superseded_by_id\" is null and \"writing_project\".\"program_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "writing_active_per_goal_uq": { + "name": "writing_active_per_goal_uq", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"writing_project\".\"kind\" = 'term_paper' and \"writing_project\".\"superseded_by_id\" is null and \"writing_project\".\"goal_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "writing_project_user_id_user_id_fk": { + "name": "writing_project_user_id_user_id_fk", + "tableFrom": "writing_project", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "writing_project_program_id_degree_program_id_fk": { + "name": "writing_project_program_id_degree_program_id_fk", + "tableFrom": "writing_project", + "tableTo": "degree_program", + "columnsFrom": [ + "program_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "writing_project_semester_id_semester_id_fk": { + "name": "writing_project_semester_id_semester_id_fk", + "tableFrom": "writing_project", + "tableTo": "semester", + "columnsFrom": [ + "semester_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "writing_project_goal_id_module_goal_id_fk": { + "name": "writing_project_goal_id_module_goal_id_fk", + "tableFrom": "writing_project", + "tableTo": "module_goal", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "writing_project_superseded_by_id_writing_project_id_fk": { + "name": "writing_project_superseded_by_id_writing_project_id_fk", + "tableFrom": "writing_project", + "tableTo": "writing_project", + "columnsFrom": [ + "superseded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_prefs": { + "name": "notification_prefs", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email_reminders": { + "name": "email_reminders", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "push_reminders": { + "name": "push_reminders", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "channels": { + "name": "channels", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "notification_prefs_user_id_user_id_fk": { + "name": "notification_prefs_user_id_user_id_fk", + "tableFrom": "notification_prefs", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_sent": { + "name": "notification_sent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "notification_sent_user_id_user_id_fk": { + "name": "notification_sent_user_id_user_id_fk", + "tableFrom": "notification_sent", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notification_sent_user_key": { + "name": "notification_sent_user_key", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_subscription": { + "name": "push_subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "p256dh": { + "name": "p256dh", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "push_subscription_userId_idx": { + "name": "push_subscription_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_subscription_user_id_user_id_fk": { + "name": "push_subscription_user_id_user_id_fk", + "tableFrom": "push_subscription", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "push_subscription_endpoint_unique": { + "name": "push_subscription_endpoint_unique", + "nullsNotDistinct": false, + "columns": [ + "endpoint" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reminder_sent": { + "name": "reminder_sent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "offset_minutes": { + "name": "offset_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "occurrence_date": { + "name": "occurrence_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "reminder_sent_event_id_event_id_fk": { + "name": "reminder_sent_event_id_event_id_fk", + "tableFrom": "reminder_sent", + "tableTo": "event", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "reminder_sent_event_offset_occurrence": { + "name": "reminder_sent_event_offset_occurrence", + "nullsNotDistinct": false, + "columns": [ + "event_id", + "offset_minutes", + "occurrence_date" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invite": { + "name": "invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "max_uses": { + "name": "max_uses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "used_count": { + "name": "used_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invite_created_by_user_id_fk": { + "name": "invite_created_by_user_id_fk", + "tableFrom": "invite", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invite_token_unique": { + "name": "invite_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.study_session": { + "name": "study_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pomodoro'" + }, + "duration_minutes": { + "name": "duration_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "study_session_userId_idx": { + "name": "study_session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "study_session_startedAt_idx": { + "name": "study_session_startedAt_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "study_session_user_id_user_id_fk": { + "name": "study_session_user_id_user_id_fk", + "tableFrom": "study_session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "study_session_module_id_module_id_fk": { + "name": "study_session_module_id_module_id_fk", + "tableFrom": "study_session", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.semester_plan": { + "name": "semester_plan", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "semester_id": { + "name": "semester_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "availability": { + "name": "availability", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "week_overrides": { + "name": "week_overrides", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "stale_at": { + "name": "stale_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_warnings": { + "name": "last_warnings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "generated_at": { + "name": "generated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "semester_plan_user_idx": { + "name": "semester_plan_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "semester_plan_user_id_user_id_fk": { + "name": "semester_plan_user_id_user_id_fk", + "tableFrom": "semester_plan", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "semester_plan_semester_id_semester_id_fk": { + "name": "semester_plan_semester_id_semester_id_fk", + "tableFrom": "semester_plan", + "tableTo": "semester", + "columnsFrom": [ + "semester_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "semester_plan_semester_id_unique": { + "name": "semester_plan_semester_id_unique", + "nullsNotDistinct": false, + "columns": [ + "semester_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.module_plan": { + "name": "module_plan", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weight": { + "name": "weight", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true, + "default": "'1'" + }, + "weekly_hours_target": { + "name": "weekly_hours_target", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "preferred_weekdays": { + "name": "preferred_weekdays", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "module_plan_module_idx": { + "name": "module_plan_module_idx", + "columns": [ + { + "expression": "module_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "module_plan_module_id_module_id_fk": { + "name": "module_plan_module_id_module_id_fk", + "tableFrom": "module_plan", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "module_plan_module_id_unique": { + "name": "module_plan_module_id_unique", + "nullsNotDistinct": false, + "columns": [ + "module_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plan_session": { + "name": "plan_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "semester_plan_id": { + "name": "semester_plan_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "start_time": { + "name": "start_time", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration_minutes": { + "name": "duration_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'study'" + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "done": { + "name": "done", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plan_session_plan_date_idx": { + "name": "plan_session_plan_date_idx", + "columns": [ + { + "expression": "semester_plan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plan_session_semester_plan_id_semester_plan_id_fk": { + "name": "plan_session_semester_plan_id_semester_plan_id_fk", + "tableFrom": "plan_session", + "tableTo": "semester_plan", + "columnsFrom": [ + "semester_plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plan_session_module_id_module_id_fk": { + "name": "plan_session_module_id_module_id_fk", + "tableFrom": "plan_session", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plan_task": { + "name": "plan_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "estimated_minutes": { + "name": "estimated_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60 + }, + "done": { + "name": "done", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "due_date": { + "name": "due_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"kind\":\"manual\"}'::jsonb" + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "ai_generated": { + "name": "ai_generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plan_task_module_idx": { + "name": "plan_task_module_idx", + "columns": [ + { + "expression": "module_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plan_task_session_idx": { + "name": "plan_task_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plan_task_module_id_module_id_fk": { + "name": "plan_task_module_id_module_id_fk", + "tableFrom": "plan_task", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plan_task_goal_id_module_goal_id_fk": { + "name": "plan_task_goal_id_module_goal_id_fk", + "tableFrom": "plan_task", + "tableTo": "module_goal", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "plan_task_session_id_plan_session_id_fk": { + "name": "plan_task_session_id_plan_session_id_fk", + "tableFrom": "plan_task", + "tableTo": "plan_session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.module_outline": { + "name": "module_outline", + "schema": "", + "columns": { + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "topic_count": { + "name": "topic_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "module_outline_module_id_module_id_fk": { + "name": "module_outline_module_id_module_id_fk", + "tableFrom": "module_outline", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "module_outline_user_id_user_id_fk": { + "name": "module_outline_user_id_user_id_fk", + "tableFrom": "module_outline", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outline_topic": { + "name": "outline_topic", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title_key": { + "name": "title_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_material_ids": { + "name": "source_material_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "outline_topic_module_idx": { + "name": "outline_topic_module_idx", + "columns": [ + { + "expression": "module_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outline_topic_module_version_idx": { + "name": "outline_topic_module_version_idx", + "columns": [ + { + "expression": "module_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "outline_topic_module_id_module_id_fk": { + "name": "outline_topic_module_id_module_id_fk", + "tableFrom": "outline_topic", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "outline_topic_user_id_user_id_fk": { + "name": "outline_topic_user_id_user_id_fk", + "tableFrom": "outline_topic", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "outline_topic_parent_id_outline_topic_id_fk": { + "name": "outline_topic_parent_id_outline_topic_id_fk", + "tableFrom": "outline_topic", + "tableTo": "outline_topic", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 2544c24..8093fc1 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -288,6 +288,20 @@ "when": 1784402733865, "tag": "0040_plan_cutover", "breakpoints": true + }, + { + "idx": 41, + "version": "7", + "when": 1784466080725, + "tag": "0041_plan_consolidation", + "breakpoints": true + }, + { + "idx": 42, + "version": "7", + "when": 1784468692682, + "tag": "0042_search_and_locale", + "breakpoints": true } ] } \ No newline at end of file diff --git a/messages/de.json b/messages/de.json index 7e79ccc..1775b69 100644 --- a/messages/de.json +++ b/messages/de.json @@ -340,7 +340,7 @@ "saved": "Profil gespeichert." }, "aiKeys": { - "title": "Eigene KI-API-Keys (BYOK)", + "title": "Eigene KI-API-Keys", "description": "Eigene Keys haben Vorrang vor den zentral konfigurierten. Verschlüsselt gespeichert.", "placeholder": "API-Key eingeben", "active": "Eigener Key aktiv", @@ -366,10 +366,11 @@ "unsupported": "Dieser Browser unterstützt keine Push-Benachrichtigungen.", "denied": "Benachrichtigungen wurden im Browser blockiert.", "swUnavailable": "Der Hintergrunddienst für Push ist nicht verfügbar. Lade die Seite neu und versuche es erneut.", - "saveFailed": "Einstellung konnte nicht gespeichert werden." + "saveFailed": "Einstellung konnte nicht gespeichert werden.", + "pushInactiveHint": "Push-Benachrichtigungen sind auf diesem Gerät nicht aktiviert — die Push-Schalter wirken erst, wenn du unten Push aktivierst." }, "usage": { - "title": "Dein Token-Verbrauch (30 Tage)", + "title": "Deine KI-Nutzung (30 Tage)", "input": "Input", "output": "Output", "limit": "Limit", @@ -656,7 +657,9 @@ "4": "Do", "5": "Fr", "6": "Sa" - } + }, + "deleteOccurrence": "Nur dieses Vorkommen löschen", + "deleteSeries": "Ganze Serie löschen" }, "ics": { "title": "Kalender-Abo (ICS)", @@ -715,7 +718,8 @@ "planItems": "{count} Lerneinheiten heute", "examIn": "{title} in {days} Tagen", "prepared": "{percent} % vorbereitet", - "start": "Jetzt lernen" + "start": "Jetzt lernen", + "viewPlan": "Plan ansehen" } }, "stats": { @@ -787,7 +791,10 @@ "viewer": { "unsupported": "Vorschau für diesen Dateityp nicht verfügbar — bitte herunterladen.", "fileMissing": "Datei nicht mehr vorhanden", - "fileMissingHint": "Der Eintrag existiert, aber die Datei fehlt auf dem Server (z. B. durch ein früheres Redeploy ohne persistentes Upload-Volume verloren). Bitte lade die Datei neu hoch." + "fileMissingHint": "Der Eintrag existiert, aber die Datei fehlt auf dem Server (z. B. durch ein früheres Redeploy ohne persistentes Upload-Volume verloren). Bitte lade die Datei neu hoch.", + "processingTitle": "Dokument wird verarbeitet", + "processingHint": "Chat und Suche sind verfügbar, sobald die Verarbeitung abgeschlossen ist.", + "processingFailedTitle": "Verarbeitung fehlgeschlagen" }, "annotator": { "hint": "Auf der Seite ziehen zum Markieren; Markierung anklicken für Notiz oder Löschen.", @@ -828,11 +835,12 @@ "skippedHint": "Aus dieser Datei konnte kein Text gewonnen werden.", "retry": "Neu verarbeiten" }, - "notIndexedHint": "Es ist kein Embedding-Modell konfiguriert — Materialien werden nicht für die AI-Suche indexiert. Ein Admin kann das unter Admin → AI einrichten.", + "notIndexedHint": "Die KI-Suche ist noch nicht eingerichtet.", "uploadRateLimited": "Zu viele Uploads in kurzer Zeit — bitte kurz warten.", "uploadNetworkError": "Netzwerkfehler beim Upload — Verbindung prüfen und erneut versuchen.", "uploadServerError": "{name}: Serverfehler beim Speichern. Prüfe im Admin-Bereich, ob der Datei-Speicher beschreibbar ist.", - "askDocument": "Im Chat fragen" + "askDocument": "Im Chat fragen", + "askDocumentProcessing": "Dokument wird noch verarbeitet" }, "search": { "modules": "Module", @@ -848,7 +856,11 @@ "title": "KI-Assistent", "newChat": "Neuer Chat", "noConversations": "Noch keine Unterhaltungen.", - "notConfigured": "Es sind noch keine KI-Provider konfiguriert.", + "notConfigured": { + "title": "KI ist nicht eingerichtet", + "description": "Es ist kein KI-Modell konfiguriert. Richte es in den Einstellungen ein, um den Chat zu nutzen.", + "cta": "Zu den Einstellungen" + }, "configureNow": "Jetzt im Admin-Panel konfigurieren", "emptyChat": "Stelle eine Frage zu deinem Studium.", "quickChat": "KI-Chat", @@ -873,6 +885,25 @@ "createQuizWithQuestions": "Quiz anlegen", "createCalendarEvent": "Termin anlegen", "createAssignment": "Abgabe anlegen" + }, + "fields": { + "name": "Name", + "title": "Titel", + "module": "Modul", + "type": "Art", + "startsAt": "Beginn", + "endsAt": "Ende", + "location": "Ort", + "dueDate": "Fällig", + "cards": "Karten", + "questions": "Fragen", + "pointsMax": "Punkte" + }, + "eventTypes": { + "exam": "Prüfung", + "deadline": "Frist", + "lecture": "Vorlesung", + "other": "Sonstiges" } }, "suggestions": { @@ -930,7 +961,9 @@ "stop": "Aufnahme beenden", "empty": "Keine Sprache erkannt.", "micDenied": "Mikrofonzugriff wurde verweigert." - } + }, + "errorNoKey": "Für dieses KI-Modell ist kein API-Key hinterlegt.", + "errorNoKeyCta": "In den Einstellungen einrichten" }, "learn": { "title": "Lernen", @@ -975,6 +1008,9 @@ "name": "Name", "description": "Beschreibung", "empty": "Noch keine Decks.", + "mistakesName": "Quiz-Fehler", + "mistakesBadge": "Auto", + "mistakesTooltip": "Automatisch aus falsch beantworteten Quizfragen befüllt.", "cards": "{count} Karten", "due": "{count} fällig", "study": "Lernen", @@ -1040,7 +1076,14 @@ "description": "Beschreibung (optional)", "correctAnswer": "Richtige Antwort", "edit": "Bearbeiten", - "notGraded": "Nicht automatisch bewertet (kein AI-Provider) — vergleiche mit der Referenzantwort." + "notGraded": "Nicht automatisch bewertet (kein AI-Provider) — vergleiche mit der Referenzantwort.", + "resultNotGraded": "Ergebnis: nicht bewertet", + "notScored": "nicht bewertet", + "mistakesAdded": "{count} Karten zum Fehler-Deck hinzugefügt", + "openMistakesDeck": "Fehler-Deck öffnen", + "previous": "Zurück", + "skip": "Überspringen", + "gradingCount": "Bewerte {count} Antworten …" }, "aiUnavailable": "Kein KI-Modell konfiguriert — KI-Funktionen deaktiviert.", "questionForm": { @@ -1071,7 +1114,8 @@ "produced": "{count} erzeugt", "completed": "Fertig — {count} Elemente, {total} Themen abgedeckt.", "failed": "Fehlgeschlagen: {error}", - "close": "Schließen" + "close": "Schließen", + "failedGeneric": "Die Generierung ist fehlgeschlagen. Bitte versuche es erneut." }, "selectModuleFirst": "Bitte wähle zuerst ein Modul aus." }, @@ -1179,7 +1223,10 @@ "cta": "Jetzt einrichten", "aiTitle": "KI-Funktionen aktivieren", "aiDesc": "Als Administrator kannst du einen KI-Anbieter hinterlegen, um Chat, Quiz- und Karten-Generierung freizuschalten.", - "aiCta": "KI einrichten" + "aiCta": "KI einrichten", + "planTitle": "Lernplan einrichten", + "planDesc": "Lege deine Verfügbarkeit fest und berechne deinen persönlichen Lernplan.", + "planCta": "Zum Lernplan" }, "context": { "program": "Studiengang", @@ -1229,6 +1276,15 @@ "4": "Do", "5": "Fr", "6": "Sa" + }, + "weekOverride": { + "title": "Diese Woche weniger Zeit", + "hint": "Begrenze die Lernzeit für eine bestimmte Woche (z. B. wegen Urlaub oder Prüfungsstress).", + "weekLabel": "Woche", + "hoursLabel": "Stunden", + "hours": "{hours} h", + "add": "Hinzufügen", + "remove": "Entfernen" } }, "plan": { @@ -1262,7 +1318,9 @@ "due": "bis", "scheduled": "Verplant", "goal": "Lernziel", - "noGoal": "Kein Lernziel" + "noGoal": "Kein Lernziel", + "outlineNudge": "Für die Prüfung gibt es noch keine Gliederung und keine Aufgaben. Lege eine Gliederung an oder leite sie aus deinen Materialien ab.", + "outlineNudgeCta": "Zu den Materialien" }, "prefs": { "title": "Planungs-Einstellungen", @@ -1273,7 +1331,8 @@ "weekdays": "Bevorzugte Wochentage", "save": "Speichern", "saved": "Gespeichert.", - "linkToSemester": "Zur Semester-Koordination" + "linkToSemester": "Zur Semester-Koordination", + "weightHint": "Gewichtung: Wie viel deiner Lernzeit dieses Modul im Vergleich bekommt. 2 heißt doppelt so viel Zeit wie ein Modul mit 1." }, "sessions": { "title": "Kommende Termine", @@ -1303,6 +1362,56 @@ "done": "Erledigt", "tasks": "Aufgaben", "noTasks": "Keine Aufgaben in dieser Einheit." + }, + "recompute": { + "summary": "Plan aktualisiert: {sessions} Sitzungen, {warnings} Warnungen" + }, + "staleness": { + "message": "Dein Plan ist veraltet — neu berechnen.", + "action": "Plan berechnen" + }, + "catchUp": { + "message": "{count, plural, one {# verpasste Session} other {# verpasste Sessions}} — nachholen.", + "action": "Nachholen" + }, + "readiness": { + "on_track": "Auf Kurs", + "at_risk": "Knapp", + "unreachable": "Nicht schaffbar", + "setupHint": "Plan einrichten" + }, + "warningText": { + "deadline_unreachable": "Die Zeit reicht nicht für alle Aufgaben vor der Prüfung. Erhöhe deine Verfügbarkeit oder verschiebe bzw. entferne Aufgaben.", + "no_capacity": "Zu wenig freie Zeit im Zeitraum. Trage mehr verfügbare Zeitfenster ein.", + "horizon_clipped": "Einige Aufgaben liegen zu weit in der Zukunft und wurden noch nicht verplant. Sie erscheinen näher am Termin." + }, + "checklist": { + "title": "Einrichtung abschließen", + "progress": "{done} von {total} erledigt", + "dismiss": "Ausblenden", + "go": "Los", + "steps": { + "module": { + "title": "Modul angelegt", + "desc": "Lege ein Modul für dein Fach an." + }, + "examDate": { + "title": "Prüfungstermin gesetzt", + "desc": "Trage das Datum deiner Prüfung ein." + }, + "availability": { + "title": "Verfügbarkeit konfiguriert", + "desc": "Lege fest, wann du wöchentlich lernen kannst." + }, + "tasks": { + "title": "Aufgaben generiert", + "desc": "Erstelle Aufgaben aus deinen Lernzielen." + }, + "plan": { + "title": "Plan berechnet", + "desc": "Berechne deinen Lernplan." + } + } } }, "learnSession": { @@ -1407,7 +1516,8 @@ "none": "Kein Bonus", "percent_points": "Prozentpunkte auf Ergebnis", "grade_steps": "Notenschritte besser" - } + }, + "minCompletedShareHint": "Anteil als Dezimalzahl zwischen 0 und 1 (z. B. 0,5 = die Hälfte der Abgaben muss erledigt sein)." } }, "goalCard": { @@ -1442,5 +1552,54 @@ "home": { "title": "Willkommen bei StudyHelper", "description": "Die Open-Source-App für dein gesamtes Studium: Planung, Lernmaterialien, KI-Unterstützung und Prüfungsvorbereitung — self-hosted und offline-fähig." + }, + "errors": { + "PLAN_NO_AVAILABILITY": "Lege zuerst deine Verfügbarkeit fest, um den Plan zu berechnen.", + "PLAN_INVALID_GOAL": "Das ausgewählte Lernziel gehört nicht zu diesem Modul.", + "GOAL_MAX_ATTEMPTS": "Maximale Versuchszahl erreicht.", + "WRITING_NO_GOAL": "Dieses Modul hat kein Schreib-Lernziel (Hausarbeit, Thesis oder Projekt).", + "WRITING_NO_DUE_DATE": "Lege zuerst ein Abgabedatum fest.", + "THESIS_ACTIVE_EXISTS": "Für diesen Studiengang existiert bereits eine aktive Abschlussarbeit.", + "THESIS_MAX_ATTEMPTS": "Maximale Versuchszahl für die Abschlussarbeit erreicht.", + "AI_NO_MODEL": "Es ist kein KI-Modell konfiguriert. Ein Admin kann das unter Admin → KI einrichten.", + "LIMIT_EXCEEDED": "Dein monatliches KI-Kontingent ist aufgebraucht.", + "MATERIAL_NAME_REQUIRED": "Bitte gib einen Namen ein.", + "FOLDER_DUPLICATE": "Ein Ordner mit diesem Namen existiert hier bereits.", + "GENERIC": "Etwas ist schiefgelaufen. Bitte versuche es erneut.", + "AI_SETUP_REQUIRED": "Hinterlege deinen API-Key in den Einstellungen, um KI-Funktionen zu nutzen." + }, + "notifications": { + "eventTitle": "⏰ {title}", + "eventBody": "{when}", + "eventBodyLocation": "{when} · {location}", + "assignmentTitle": "📋 {title}", + "assignmentBody": "{module} · {due}", + "dailyPlanTitle": "📚 {appName}", + "dailyPlanBody": "{count, plural, one {Heute steht # Lerneinheit in deinem Lernplan.} other {Heute stehen # Lerneinheiten in deinem Lernplan.}}" + }, + "usage": { + "features": { + "chat": "Chat", + "flashcards": "Karteikarten", + "quiz": "Quiz", + "quiz-grading": "Quiz-Bewertung", + "outline": "Gliederung", + "summary": "Zusammenfassung", + "summary-doc": "Dokument-Zusammenfassung", + "summary-section": "Abschnitts-Zusammenfassung", + "analysis": "Analyse", + "explain": "Erklärung", + "embedding": "Embedding", + "embedding-dedup": "Embedding (Duplikate)", + "embedding-query": "Embedding (Suche)", + "rerank": "Reranking", + "ocr": "Texterkennung (OCR)", + "transcription": "Transkription", + "voice-input": "Spracheingabe", + "thesis-topics": "Themen-Ideen (Abschlussarbeit)", + "writing-milestones": "Meilensteine (Schreiben)", + "writing-outline": "Gliederung (Schreiben)", + "writing-sources": "Quellen-Vorschläge (Schreiben)" + } } } diff --git a/messages/en.json b/messages/en.json index 4485476..d8138eb 100644 --- a/messages/en.json +++ b/messages/en.json @@ -340,7 +340,7 @@ "saved": "Profile saved." }, "aiKeys": { - "title": "Your AI API keys (BYOK)", + "title": "Your AI API keys", "description": "Your own keys take precedence over centrally configured ones. Stored encrypted.", "placeholder": "Enter API key", "active": "Own key active", @@ -366,10 +366,11 @@ "unsupported": "This browser does not support push notifications.", "denied": "Notifications are blocked in the browser.", "swUnavailable": "The background service for push is unavailable. Reload the page and try again.", - "saveFailed": "The setting could not be saved." + "saveFailed": "The setting could not be saved.", + "pushInactiveHint": "Push notifications aren't enabled on this device — the push switches only take effect once you enable push below." }, "usage": { - "title": "Your token usage (30 days)", + "title": "Your AI usage (30 days)", "input": "Input", "output": "Output", "limit": "Limit", @@ -656,7 +657,9 @@ "4": "Th", "5": "Fr", "6": "Sa" - } + }, + "deleteOccurrence": "Delete only this occurrence", + "deleteSeries": "Delete entire series" }, "ics": { "title": "Calendar subscription (ICS)", @@ -715,7 +718,8 @@ "planItems": "{count} study sessions today", "examIn": "{title} in {days} days", "prepared": "{percent}% prepared", - "start": "Start learning" + "start": "Start learning", + "viewPlan": "View plan" } }, "stats": { @@ -787,7 +791,10 @@ "viewer": { "unsupported": "No preview available for this file type — please download.", "fileMissing": "File no longer available", - "fileMissingHint": "The entry exists, but the file is missing on the server (e.g. lost in an earlier redeploy without a persistent upload volume). Please upload it again." + "fileMissingHint": "The entry exists, but the file is missing on the server (e.g. lost in an earlier redeploy without a persistent upload volume). Please upload it again.", + "processingTitle": "Document is being processed", + "processingHint": "Chat and search become available once processing is complete.", + "processingFailedTitle": "Processing failed" }, "annotator": { "hint": "Drag on a page to highlight; click a highlight to add a note or delete it.", @@ -828,11 +835,12 @@ "skippedHint": "No text could be extracted from this file.", "retry": "Process again" }, - "notIndexedHint": "No embedding model is configured — materials are not indexed for AI search. An admin can set this up under Admin → AI.", + "notIndexedHint": "AI search isn't set up yet.", "uploadRateLimited": "Too many uploads in a short time — please wait a moment.", "uploadNetworkError": "Network error during upload — check your connection and try again.", "uploadServerError": "{name}: Server error while saving. Check in the admin area whether file storage is writable.", - "askDocument": "Ask in chat" + "askDocument": "Ask in chat", + "askDocumentProcessing": "Document is still being processed" }, "search": { "modules": "Modules", @@ -848,7 +856,11 @@ "title": "AI Assistant", "newChat": "New chat", "noConversations": "No conversations yet.", - "notConfigured": "No AI providers are configured yet.", + "notConfigured": { + "title": "AI isn't set up", + "description": "No AI model is configured. Set it up in Settings to use chat.", + "cta": "Go to Settings" + }, "configureNow": "Configure now in the admin panel", "emptyChat": "Ask anything about your studies.", "quickChat": "AI chat", @@ -872,6 +884,25 @@ "createQuizWithQuestions": "Create quiz", "createCalendarEvent": "Create event", "createAssignment": "Create assignment" + }, + "fields": { + "name": "Name", + "title": "Title", + "module": "Module", + "type": "Type", + "startsAt": "Starts", + "endsAt": "Ends", + "location": "Location", + "dueDate": "Due", + "cards": "Cards", + "questions": "Questions", + "pointsMax": "Points" + }, + "eventTypes": { + "exam": "Exam", + "deadline": "Deadline", + "lecture": "Lecture", + "other": "Other" } }, "suggestions": { @@ -930,7 +961,9 @@ "stop": "Stop recording", "empty": "No speech detected.", "micDenied": "Microphone access was denied." - } + }, + "errorNoKey": "No API key is set for this AI model.", + "errorNoKeyCta": "Set it up in Settings" }, "learn": { "title": "Learn", @@ -975,6 +1008,9 @@ "name": "Name", "description": "Description", "empty": "No decks yet.", + "mistakesName": "Quiz mistakes", + "mistakesBadge": "Auto", + "mistakesTooltip": "Automatically filled from quiz questions you answered incorrectly.", "cards": "{count} cards", "due": "{count} due", "study": "Study", @@ -1040,7 +1076,14 @@ "description": "Description (optional)", "correctAnswer": "Correct answer", "edit": "Edit", - "notGraded": "Not graded automatically (no AI configured) — compare with the reference answer." + "notGraded": "Not graded automatically (no AI configured) — compare with the reference answer.", + "resultNotGraded": "Result: not graded", + "notScored": "not graded", + "mistakesAdded": "{count} cards added to the mistakes deck", + "openMistakesDeck": "Open mistakes deck", + "previous": "Back", + "skip": "Skip", + "gradingCount": "Grading {count} answers …" }, "aiUnavailable": "No AI model configured — AI features disabled.", "questionForm": { @@ -1071,7 +1114,8 @@ "produced": "{count} created", "completed": "Done — {count} items, {total} topics covered.", "failed": "Failed: {error}", - "close": "Close" + "close": "Close", + "failedGeneric": "Generation failed. Please try again." }, "selectModuleFirst": "Please select a module first." }, @@ -1179,7 +1223,10 @@ "cta": "Set up now", "aiTitle": "Enable AI features", "aiDesc": "As an admin you can add an AI provider to unlock chat, quiz and flashcard generation.", - "aiCta": "Set up AI" + "aiCta": "Set up AI", + "planTitle": "Set up your study plan", + "planDesc": "Set your availability and compute your personal study plan.", + "planCta": "To study plan" }, "context": { "program": "Degree program", @@ -1229,6 +1276,15 @@ "4": "Th", "5": "Fr", "6": "Sa" + }, + "weekOverride": { + "title": "Less time this week", + "hint": "Cap the study time for a specific week (e.g. holidays or exam crunch).", + "weekLabel": "Week", + "hoursLabel": "Hours", + "hours": "{hours} h", + "add": "Add", + "remove": "Remove" } }, "plan": { @@ -1262,7 +1318,9 @@ "due": "by", "scheduled": "Scheduled", "goal": "Goal", - "noGoal": "No goal" + "noGoal": "No goal", + "outlineNudge": "There's no outline and no tasks for this exam yet. Create an outline or derive one from your materials.", + "outlineNudgeCta": "To materials" }, "prefs": { "title": "Planning settings", @@ -1273,7 +1331,8 @@ "weekdays": "Preferred weekdays", "save": "Save", "saved": "Saved.", - "linkToSemester": "To semester coordination" + "linkToSemester": "To semester coordination", + "weightHint": "Weight: how much of your study time this module gets relative to others. 2 means twice as much time as a module with 1." }, "sessions": { "title": "Upcoming sessions", @@ -1303,6 +1362,56 @@ "done": "Done", "tasks": "Tasks", "noTasks": "No tasks in this session." + }, + "recompute": { + "summary": "Plan updated: {sessions} sessions, {warnings} warnings" + }, + "staleness": { + "message": "Your plan is out of date — recompute it.", + "action": "Compute plan" + }, + "catchUp": { + "message": "{count, plural, one {# missed session} other {# missed sessions}} — catch up.", + "action": "Catch up" + }, + "readiness": { + "on_track": "On track", + "at_risk": "At risk", + "unreachable": "Unreachable", + "setupHint": "Set up plan" + }, + "warningText": { + "deadline_unreachable": "There isn't enough time for all tasks before the exam. Add more availability or move/remove tasks.", + "no_capacity": "Too little free time in the period. Add more available time windows.", + "horizon_clipped": "Some tasks are too far in the future and haven't been scheduled yet. They'll appear closer to the date." + }, + "checklist": { + "title": "Finish setup", + "progress": "{done} of {total} done", + "dismiss": "Dismiss", + "go": "Go", + "steps": { + "module": { + "title": "Module created", + "desc": "Create a module for your subject." + }, + "examDate": { + "title": "Exam date set", + "desc": "Enter the date of your exam." + }, + "availability": { + "title": "Availability configured", + "desc": "Set when you can study each week." + }, + "tasks": { + "title": "Tasks generated", + "desc": "Create tasks from your goals." + }, + "plan": { + "title": "Plan computed", + "desc": "Compute your study plan." + } + } } }, "learnSession": { @@ -1407,7 +1516,8 @@ "none": "No bonus", "percent_points": "Percentage points on result", "grade_steps": "Grade steps better" - } + }, + "minCompletedShareHint": "Share as a decimal between 0 and 1 (e.g. 0.5 = half of the submissions must be done)." } }, "goalCard": { @@ -1442,5 +1552,54 @@ "home": { "title": "Welcome to StudyHelper", "description": "The open-source app for your entire studies: planning, learning materials, AI support and exam preparation — self-hosted and offline-capable." + }, + "errors": { + "PLAN_NO_AVAILABILITY": "Set your availability first to compute the plan.", + "PLAN_INVALID_GOAL": "The selected goal doesn't belong to this module.", + "GOAL_MAX_ATTEMPTS": "Maximum number of attempts reached.", + "WRITING_NO_GOAL": "This module has no writing goal (term paper, thesis or project).", + "WRITING_NO_DUE_DATE": "Set a due date first.", + "THESIS_ACTIVE_EXISTS": "An active thesis already exists for this program.", + "THESIS_MAX_ATTEMPTS": "Maximum number of thesis attempts reached.", + "AI_NO_MODEL": "No AI model is configured. An admin can set this up under Admin → AI.", + "LIMIT_EXCEEDED": "You've used up your monthly AI quota.", + "MATERIAL_NAME_REQUIRED": "Please enter a name.", + "FOLDER_DUPLICATE": "A folder with this name already exists here.", + "GENERIC": "Something went wrong. Please try again.", + "AI_SETUP_REQUIRED": "Add your API key in Settings to use AI features." + }, + "notifications": { + "eventTitle": "⏰ {title}", + "eventBody": "{when}", + "eventBodyLocation": "{when} · {location}", + "assignmentTitle": "📋 {title}", + "assignmentBody": "{module} · {due}", + "dailyPlanTitle": "📚 {appName}", + "dailyPlanBody": "{count, plural, one {You have # study session in today's plan.} other {You have # study sessions in today's plan.}}" + }, + "usage": { + "features": { + "chat": "Chat", + "flashcards": "Flashcards", + "quiz": "Quiz", + "quiz-grading": "Quiz grading", + "outline": "Outline", + "summary": "Summary", + "summary-doc": "Document summary", + "summary-section": "Section summary", + "analysis": "Analysis", + "explain": "Explanation", + "embedding": "Embedding", + "embedding-dedup": "Embedding (dedup)", + "embedding-query": "Embedding (search)", + "rerank": "Reranking", + "ocr": "Text recognition (OCR)", + "transcription": "Transcription", + "voice-input": "Voice input", + "thesis-topics": "Topic ideas (thesis)", + "writing-milestones": "Milestones (writing)", + "writing-outline": "Outline (writing)", + "writing-sources": "Source suggestions (writing)" + } } } diff --git a/next.config.ts b/next.config.ts index 434917f..e716bfe 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,53 @@ import type { NextConfig } from "next" import createNextIntlPlugin from "next-intl/plugin" +const isDev = process.env.NODE_ENV !== "production" + +/** + * Content-Security-Policy for the app. Enforcing (not report-only). Reasoning + * per directive — the app renders user Markdown/LaTeX (KaTeX), views PDFs via + * pdf.js (web worker), uploads via tus, and streams AI through our own routes: + * + * - default-src 'self' everything falls back to same-origin. + * - script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' + * Next.js injects inline bootstrap/hydration scripts and we don't run a + * nonce middleware, so 'unsafe-inline' is required for the app to boot. + * 'wasm-unsafe-eval' covers pdf.js' optional WASM image decoders without + * opening full eval. In dev, React Fast Refresh needs 'unsafe-eval'. + * - style-src 'self' 'unsafe-inline' + * KaTeX and Tailwind's runtime inject inline styles; there is no nonce. + * - img-src 'self' data: blob: thumbnails, KaTeX/canvas data URIs, object URLs. + * - font-src 'self' data: KaTeX ships fonts, some inlined as data URIs. + * - worker-src 'self' blob: pdf.js loads its worker from a blob URL. + * - connect-src 'self' chat/AI stream through our own API routes, not + * the browser → no provider origins needed. tus uploads hit same-origin. + * - media-src 'self' blob: audio/video preview from object URLs. + * - object-src 'none' no / plugin content. + * - base-uri 'self' / form-action 'self' / frame-ancestors 'none' + * lock down base tag, form targets, and framing (clickjacking). + */ +const scriptSrc = [ + "'self'", + "'unsafe-inline'", + "'wasm-unsafe-eval'", + ...(isDev ? ["'unsafe-eval'"] : []), +].join(" ") + +const csp = [ + "default-src 'self'", + `script-src ${scriptSrc}`, + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob:", + "font-src 'self' data:", + "worker-src 'self' blob:", + "connect-src 'self'", + "media-src 'self' blob:", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'none'", +].join("; ") + const nextConfig: NextConfig = { output: "standalone", async headers() { @@ -12,6 +59,11 @@ const nextConfig: NextConfig = { { key: "X-Frame-Options", value: "SAMEORIGIN" }, { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" }, + { key: "Content-Security-Policy", value: csp }, + { + key: "Strict-Transport-Security", + value: "max-age=63072000; includeSubDomains", + }, ], }, ] diff --git a/package-lock.json b/package-lock.json index defb139..defd38a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "@fullcalendar/core": "^6.1.21", "@fullcalendar/daygrid": "^6.1.21", "@fullcalendar/interaction": "^6.1.21", + "@fullcalendar/list": "6.1.21", "@fullcalendar/react": "^6.1.21", "@fullcalendar/timegrid": "^6.1.21", "@tus/file-store": "^2.1.0", @@ -2925,6 +2926,15 @@ "@fullcalendar/core": "~6.1.21" } }, + "node_modules/@fullcalendar/list": { + "version": "6.1.21", + "resolved": "https://registry.npmjs.org/@fullcalendar/list/-/list-6.1.21.tgz", + "integrity": "sha512-2rpIhs5pJmV7jyk4oX4bckNqurt6iHcsweE3FDYDdNpmRukPrARnyQYcaVFNVw2bnBFeR/jQW/St2MlauxF3GQ==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.21" + } + }, "node_modules/@fullcalendar/react": { "version": "6.1.21", "resolved": "https://registry.npmjs.org/@fullcalendar/react/-/react-6.1.21.tgz", diff --git a/package.json b/package.json index 812219f..b5426af 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@fullcalendar/core": "^6.1.21", "@fullcalendar/daygrid": "^6.1.21", "@fullcalendar/interaction": "^6.1.21", + "@fullcalendar/list": "^6.1.21", "@fullcalendar/react": "^6.1.21", "@fullcalendar/timegrid": "^6.1.21", "@tus/file-store": "^2.1.0", diff --git a/src/app/[locale]/(app)/admin/ai/page.tsx b/src/app/[locale]/(app)/admin/ai/page.tsx index 78501ab..13f626b 100644 --- a/src/app/[locale]/(app)/admin/ai/page.tsx +++ b/src/app/[locale]/(app)/admin/ai/page.tsx @@ -14,6 +14,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" export default async function AdminAiPage() { await requireAdmin() const t = await getTranslations("admin.ai") + const tf = await getTranslations("usage.features") const ai = await getSetting("ai") const embeddingRef = ai?.defaultEmbeddingModel @@ -114,7 +115,7 @@ export default async function AdminAiPage() { {usageByFeature.map((row) => ( - {row.feature} + {tf.has(row.feature) ? tf(row.feature) : row.feature} {row.model} diff --git a/src/app/[locale]/(app)/ai/actions.ts b/src/app/[locale]/(app)/ai/actions.ts index 093fbea..c6ed30d 100644 --- a/src/app/[locale]/(app)/ai/actions.ts +++ b/src/app/[locale]/(app)/ai/actions.ts @@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache" import { and, eq } from "drizzle-orm" import { db } from "@/db" import { aiConversation } from "@/db/schema" +import { actionError } from "@/lib/action-errors" import { requireSession } from "@/lib/auth/session" import { ownModule } from "@/lib/studies/access" @@ -95,7 +96,7 @@ export async function explainSnippet(materialId: string, snippet: string, contex await assertWithinLimit(session.user.id) const { resolveModelForUser, getLanguageModel } = await import("@/lib/ai/registry") const modelRef = await resolveModelForUser(session.user.id) - if (!modelRef) throw new Error("No AI model configured") + if (!modelRef) actionError("AI_NO_MODEL") const model = await getLanguageModel(modelRef, session.user.id) const { generateText } = await import("ai") const { GEN_PARAMS } = await import("@/lib/ai/params") diff --git a/src/app/[locale]/(app)/ai/page.tsx b/src/app/[locale]/(app)/ai/page.tsx index 25b9361..fd71c8c 100644 --- a/src/app/[locale]/(app)/ai/page.tsx +++ b/src/app/[locale]/(app)/ai/page.tsx @@ -1,16 +1,40 @@ import { desc, eq } from "drizzle-orm" +import { getLocale, getTranslations } from "next-intl/server" +import { Sparkles } from "lucide-react" import { db } from "@/db" import { aiConversation } from "@/db/schema" import { requireSession } from "@/lib/auth/session" -import { redirect } from "@/i18n/navigation" -import { getLocale } from "next-intl/server" +import { isAiAvailable } from "@/lib/ai/registry" +import { Card, CardContent } from "@/components/ui/card" +import { Link, redirect } from "@/i18n/navigation" /** The AI hub page was replaced by the chat dock — /ai now opens the most - * recent conversation in fullscreen (creating one if none exists). */ + * recent conversation in fullscreen (creating one if none exists). When no AI + * model is configured it shows a setup hint instead of creating a dead chat. */ export default async function AiPage() { const session = await requireSession() const locale = await getLocale() + // AI off: don't insert a conversation into a dead composer — show a friendly + // "not configured" state pointing at Settings (F5). + if (!(await isAiAvailable())) { + const t = await getTranslations("ai.notConfigured") + return ( +
+ + + +

{t("title")}

+

{t("description")}

+ + {t("cta")} + +
+
+
+ ) + } + const latest = await db.query.aiConversation.findFirst({ where: eq(aiConversation.userId, session.user.id), orderBy: [desc(aiConversation.updatedAt)], diff --git a/src/app/[locale]/(app)/calendar/actions.ts b/src/app/[locale]/(app)/calendar/actions.ts index 0883876..f6960d9 100644 --- a/src/app/[locale]/(app)/calendar/actions.ts +++ b/src/app/[locale]/(app)/calendar/actions.ts @@ -137,11 +137,13 @@ export async function moveEvent(eventId: string, input: unknown) { const session = await requireSession() const data = moveSchema.parse(input) const before = await ownEvent(eventId, session.user.id) + // Interpret drag/resize times through the same server-local convention as + // create/update (parseStart) so the whole calendar stays consistent (E20). await db .update(studyEvent) .set({ - startsAt: new Date(data.startsAt), - endsAt: data.endsAt ? new Date(data.endsAt) : null, + startsAt: parseStart(data.startsAt, false), + endsAt: data.endsAt ? parseStart(data.endsAt, false) : null, }) .where(and(eq(studyEvent.id, eventId), eq(studyEvent.userId, session.user.id))) await logAudit({ @@ -157,6 +159,38 @@ export async function moveEvent(eventId: string, input: unknown) { return { ok: true as const } } +/** + * Deletes a single occurrence of a recurring event by recording its (local ISO) + * date in `skip_dates` — `expandOccurrences` then skips it while the rest of the + * series survives (E18). Single-occurrence editing is intentionally deferred. + */ +export async function deleteEventOccurrence(eventId: string, occurrenceDate: string) { + const session = await requireSession() + if (!/^\d{4}-\d{2}-\d{2}$/.test(occurrenceDate)) throw new Error("Invalid date") + const before = await ownEvent(eventId, session.user.id) + if (before.recurrence === "none") { + // Not a series — a single-occurrence delete is just a delete. + return deleteEvent(eventId) + } + const next = Array.from(new Set([...(before.skipDates ?? []), occurrenceDate])) + await db + .update(studyEvent) + .set({ skipDates: next }) + .where(and(eq(studyEvent.id, eventId), eq(studyEvent.userId, session.user.id))) + await logAudit({ + userId: session.user.id, + operation: "update", + entityType: "event", + entityId: eventId, + entityLabel: `${before.title} (${occurrenceDate})`, + before, + after: { ...before, skipDates: next }, + }) + revalidatePath("/calendar") + revalidatePath("/", "layout") + return { ok: true as const } +} + export async function deleteEvent(eventId: string) { const session = await requireSession() const before = await ownEvent(eventId, session.user.id) diff --git a/src/app/[locale]/(app)/calendar/page.tsx b/src/app/[locale]/(app)/calendar/page.tsx index 8738bb2..54a092e 100644 --- a/src/app/[locale]/(app)/calendar/page.tsx +++ b/src/app/[locale]/(app)/calendar/page.tsx @@ -1,6 +1,7 @@ -import { asc, eq, ne, and, isNotNull, inArray } from "drizzle-orm" +import { asc, eq, ne, and, isNotNull, inArray, gte, lte } from "drizzle-orm" import { getTranslations } from "next-intl/server" import { db } from "@/db" +import { toIsoDate } from "@/lib/events/recurrence" import { assignment, degreeProgram, @@ -12,7 +13,7 @@ import { import { requireSession } from "@/lib/auth/session" import { env } from "@/lib/env" import { expandAbsences } from "@/lib/plan/absences" -import { CalendarView } from "@/components/calendar/calendar-view" +import { CalendarView } from "@/components/calendar/calendar-view-dynamic" import { EventDialog, type ModuleOption } from "@/components/calendar/event-dialog" import { IcsCard } from "@/components/calendar/ics-card" import { IcsImportCard } from "@/components/calendar/ics-import-card" @@ -22,9 +23,15 @@ function toLocalInputValue(d: Date): string { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}` } -export default async function CalendarPage() { +export default async function CalendarPage({ + searchParams, +}: { + searchParams: Promise<{ event?: string }> +}) { const session = await requireSession() const t = await getTranslations("calendar") + // Deep link from the command palette: focus/open this event on load (E24). + const { event: focusEventId } = await searchParams const userPlans = await db.query.semesterPlan.findMany({ where: eq(semesterPlan.userId, session.user.id), @@ -32,6 +39,11 @@ export default async function CalendarPage() { }) const planIds = userPlans.map((p) => p.id) + // Bound plan sessions to a sane window instead of loading every session ever. + const now = new Date() + const sessionFrom = toIsoDate(new Date(now.getTime() - 60 * 86400000)) + const sessionTo = toIsoDate(new Date(now.getTime() + 365 * 86400000)) + const [allEvents, prefs, programs, planSessions, dueAssignments] = await Promise.all([ db.query.studyEvent.findMany({ where: eq(studyEvent.userId, session.user.id), @@ -45,7 +57,11 @@ export default async function CalendarPage() { }), planIds.length ? db.query.planSession.findMany({ - where: inArray(planSession.semesterPlanId, planIds), + where: and( + inArray(planSession.semesterPlanId, planIds), + gte(planSession.date, sessionFrom), + lte(planSession.date, sessionTo) + ), with: { module: { columns: { name: true } }, tasks: { @@ -85,6 +101,7 @@ export default async function CalendarPage() { ({ id: e.id, title: e.title, @@ -100,6 +117,8 @@ export default async function CalendarPage() { recurrenceUntil: e.recurrenceUntil, recurrenceWeekdays: e.recurrenceWeekdays, recurrenceInterval: e.recurrenceInterval, + skipDates: e.skipDates, + aiGenerated: e.aiGenerated, }))} planSessions={planSessions.map((s) => ({ id: s.id, diff --git a/src/app/[locale]/(app)/deck-actions.ts b/src/app/[locale]/(app)/deck-actions.ts index aa600c9..058a269 100644 --- a/src/app/[locale]/(app)/deck-actions.ts +++ b/src/app/[locale]/(app)/deck-actions.ts @@ -7,8 +7,14 @@ import { getLocale } from "next-intl/server" import { z } from "zod" import { db } from "@/db" import { deck, flashcard, reviewLog } from "@/db/schema" +import { actionError } from "@/lib/action-errors" import { requireSession } from "@/lib/auth/session" -import { getLanguageModel, resolveModelForUser } from "@/lib/ai/registry" +import { + getLanguageModel, + resolveModelForUser, + userHasUsableKeyForModel, +} from "@/lib/ai/registry" +import { GEN_PARAMS, maxTokensForItems } from "@/lib/ai/params" import { searchChunks, getModuleMaterialSample } from "@/lib/ai/rag" import { assertWithinLimit } from "@/lib/ai/usage" import { runAi } from "@/lib/ai/run" @@ -280,6 +286,10 @@ export async function reviewCard(cardId: string, rating: ReviewRating) { await db.update(flashcard).set(updated).where(eq(flashcard.id, cardId)) await db.insert(reviewLog).values({ cardId, userId: session.user.id, rating }) + // Keep due-card counts (dashboard, deck cards) fresh after a review. The + // study page itself is driven by client state, so this never disrupts an + // in-progress session. + revalidatePath("/") return { ok: true as const, nextDue: updated.due.toISOString() } } @@ -302,7 +312,11 @@ export async function generateCards(input: unknown) { const deckRow = await ownDeck(data.deckId, session.user.id) const defaultModel = await resolveModelForUser(session.user.id) - if (!defaultModel) throw new Error("No AI model configured") + if (!defaultModel) actionError("AI_NO_MODEL") + // BYOK dead-end: model configured but no usable key for its provider (F4). + if (!(await userHasUsableKeyForModel(defaultModel, session.user.id))) { + actionError("AI_SETUP_REQUIRED") + } const model = await getLanguageModel(defaultModel, session.user.id) const moduleRow = deckRow.moduleId ? await ownModule(deckRow.moduleId, session.user.id) : null @@ -343,6 +357,8 @@ export async function generateCards(input: unknown) { prompt: `Create ${data.count} high-quality flashcards for spaced repetition about: ${query}. Each card has a concise question/term on the front and a precise answer/definition on the back.${examContext ? `\n${examContext}` : ""} Write all cards in ${language}, regardless of the language of the topic text or source materials.${context}`, + ...GEN_PARAMS, + maxOutputTokens: maxTokensForItems(data.count), }) ) diff --git a/src/app/[locale]/(app)/layout.tsx b/src/app/[locale]/(app)/layout.tsx index b31b085..a9f412f 100644 --- a/src/app/[locale]/(app)/layout.tsx +++ b/src/app/[locale]/(app)/layout.tsx @@ -55,7 +55,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod } />
- +
{children}
diff --git a/src/app/[locale]/(app)/learn-actions.ts b/src/app/[locale]/(app)/learn-actions.ts index 4937526..b83ea83 100644 --- a/src/app/[locale]/(app)/learn-actions.ts +++ b/src/app/[locale]/(app)/learn-actions.ts @@ -5,6 +5,7 @@ import { revalidatePath } from "next/cache" import { and, eq } from "drizzle-orm" import { z } from "zod" import { db } from "@/db" +import { actionError } from "@/lib/action-errors" import { requireSession } from "@/lib/auth/session" import { getLanguageModel, resolveModelForUser } from "@/lib/ai/registry" import { assertWithinLimit } from "@/lib/ai/usage" @@ -135,7 +136,7 @@ export async function analyzeProgress(moduleId: string) { : "" const defaultModel = await resolveModelForUser(session.user.id) - if (!defaultModel) throw new Error("No AI model configured") + if (!defaultModel) actionError("AI_NO_MODEL") const model = await getLanguageModel(defaultModel, session.user.id) const { text } = await runAi( diff --git a/src/app/[locale]/(app)/materials-actions.ts b/src/app/[locale]/(app)/materials-actions.ts index 7d22f92..c394df5 100644 --- a/src/app/[locale]/(app)/materials-actions.ts +++ b/src/app/[locale]/(app)/materials-actions.ts @@ -5,6 +5,7 @@ import { and, eq, inArray } from "drizzle-orm" import { z } from "zod" import { db } from "@/db" import { material, materialAnnotation, materialFolder } from "@/db/schema" +import { actionError } from "@/lib/action-errors" import { requireSession } from "@/lib/auth/session" import { logAudit } from "@/lib/audit" import { deleteFile } from "@/lib/storage" @@ -83,7 +84,7 @@ export async function retryMaterialProcessing(materialId: string) { export async function renameMaterial(materialId: string, name: string) { const session = await requireSession() const clean = name.trim().slice(0, 300) - if (!clean) throw new Error("Name required") + if (!clean) actionError("MATERIAL_NAME_REQUIRED") const before = await ownMaterial(materialId, session.user.id) await db.update(material).set({ name: clean }).where(eq(material.id, materialId)) await logAudit({ @@ -165,7 +166,7 @@ export async function createFolder(input: unknown) { const parentId = data.parentId ?? null await assertFolderInModule(parentId, data.moduleId, session.user.id) const name = sanitizeSegment(data.name) - if (!name) throw new Error("Name required") + if (!name) actionError("MATERIAL_NAME_REQUIRED") let created try { ;[created] = await db @@ -173,7 +174,7 @@ export async function createFolder(input: unknown) { .values({ userId: session.user.id, moduleId: data.moduleId, parentId, name }) .returning() } catch { - throw new Error("A folder with this name already exists here") + actionError("FOLDER_DUPLICATE") } await logAudit({ userId: session.user.id, @@ -191,11 +192,11 @@ export async function renameFolder(folderId: string, newName: string) { const session = await requireSession() const before = await ownFolder(folderId, session.user.id) const name = sanitizeSegment(newName) - if (!name) throw new Error("Name required") + if (!name) actionError("MATERIAL_NAME_REQUIRED") try { await db.update(materialFolder).set({ name }).where(eq(materialFolder.id, folderId)) } catch { - throw new Error("A folder with this name already exists here") + actionError("FOLDER_DUPLICATE") } await logAudit({ userId: session.user.id, @@ -224,7 +225,7 @@ export async function moveFolder(folderId: string, newParentId: string | null) { try { await db.update(materialFolder).set({ parentId: newParentId }).where(eq(materialFolder.id, folderId)) } catch { - throw new Error("A folder with this name already exists in the target") + actionError("FOLDER_DUPLICATE") } revalidatePath("/", "layout") return { ok: true as const } diff --git a/src/app/[locale]/(app)/materials/[id]/page.tsx b/src/app/[locale]/(app)/materials/[id]/page.tsx index 952fd4b..521726c 100644 --- a/src/app/[locale]/(app)/materials/[id]/page.tsx +++ b/src/app/[locale]/(app)/materials/[id]/page.tsx @@ -1,6 +1,6 @@ import { notFound } from "next/navigation" import { and, eq } from "drizzle-orm" -import { ArrowLeft, Download } from "lucide-react" +import { AlertTriangle, ArrowLeft, Download, Loader2 } from "lucide-react" import { getTranslations } from "next-intl/server" import { asc } from "drizzle-orm" import { db } from "@/db" @@ -66,13 +66,34 @@ export default async function MaterialViewerPage({

{row.name}

{row.module.name}

- + + {["pending", "extracting", "embedding", "summarizing"].includes(row.extractionStatus) && ( +
+ +
+

{t("viewer.processingTitle")}

+

+ {t(`processing.${row.extractionStatus}`)} — {t("viewer.processingHint")} +

+
+
+ )} + {row.extractionStatus === "failed" && ( +
+ +
+

{t("viewer.processingFailedTitle")}

+ {row.extractionError &&

{row.extractionError}

} +
+
+ )} + {!fileAvailable ? ( diff --git a/src/app/[locale]/(app)/page.tsx b/src/app/[locale]/(app)/page.tsx index c0204af..dfa434f 100644 --- a/src/app/[locale]/(app)/page.tsx +++ b/src/app/[locale]/(app)/page.tsx @@ -111,15 +111,20 @@ export default async function DashboardPage() { : [] // Next upcoming exam of the active program's modules, with its preparedness. - const nextExamRow = await db.query.studyEvent.findFirst({ - where: and( - eq(studyEvent.userId, session.user.id), - eq(studyEvent.type, "exam"), - gte(studyEvent.startsAt, new Date()) - ), - orderBy: [asc(studyEvent.startsAt)], - with: { module: { columns: { id: true, name: true } } }, - }) + // Scope to the active program's module ids so exams from other programs don't + // surface on this program's dashboard (E16). + const nextExamRow = activeModuleIds.length + ? await db.query.studyEvent.findFirst({ + where: and( + eq(studyEvent.userId, session.user.id), + eq(studyEvent.type, "exam"), + gte(studyEvent.startsAt, new Date()), + inArray(studyEvent.moduleId, activeModuleIds) + ), + orderBy: [asc(studyEvent.startsAt)], + with: { module: { columns: { id: true, name: true } } }, + }) + : null const nextExam: NextExam | null = nextExamRow ? { title: nextExamRow.title, diff --git a/src/app/[locale]/(app)/plan/[semesterId]/page.tsx b/src/app/[locale]/(app)/plan/[semesterId]/page.tsx index fc65369..7fc6615 100644 --- a/src/app/[locale]/(app)/plan/[semesterId]/page.tsx +++ b/src/app/[locale]/(app)/plan/[semesterId]/page.tsx @@ -1,10 +1,12 @@ -import { and, asc, eq, gte, inArray } from "drizzle-orm" +import { and, asc, eq, gte, inArray, lt } from "drizzle-orm" import { getTranslations } from "next-intl/server" import { db } from "@/db" import { modulePlan, planSession, semesterPlan, studyModule } from "@/db/schema" import { requireSession } from "@/lib/auth/session" import { ownSemester } from "@/lib/studies/access" import { AvailabilityEditor } from "@/components/plan/availability-editor" +import { PlanBanners } from "@/components/plan/plan-banners" +import { type SetupStep } from "@/components/plan/setup-checklist" import { StrategyBoard, type StrategyModule } from "@/components/plan/strategy-board" export default async function SemesterPlanPage({ @@ -19,7 +21,13 @@ export default async function SemesterPlanPage({ const plan = await db.query.semesterPlan.findFirst({ where: eq(semesterPlan.semesterId, semesterId), - columns: { id: true, availability: true, generatedAt: true }, + columns: { + id: true, + availability: true, + generatedAt: true, + staleAt: true, + weekOverrides: true, + }, }) const modules = await db.query.studyModule.findMany({ @@ -48,6 +56,27 @@ export default async function SemesterPlanPage({ }) : [] + // A10 catch-up: past sessions that were never done and still carry open tasks. + const pastSessions = plan + ? await db.query.planSession.findMany({ + where: and( + eq(planSession.semesterPlanId, plan.id), + lt(planSession.date, today), + eq(planSession.done, false) + ), + columns: { id: true }, + with: { tasks: { columns: { done: true } } }, + }) + : [] + const missedCount = pastSessions.filter((s) => s.tasks.some((t) => !t.done)).length + + const hasAvailability = (plan?.availability.weekly?.length ?? 0) > 0 + const setupSteps: SetupStep[] = [ + { key: "module", done: modules.length > 0, href: `/studies/${sem.programId}` }, + { key: "availability", done: hasAvailability, href: `/plan/${semesterId}` }, + { key: "plan", done: sessions.length > 0 || plan?.generatedAt != null, href: `/plan/${semesterId}` }, + ] + const strategyModules: StrategyModule[] = modules.map((m) => { const mp = planByModule.get(m.id) return { @@ -71,12 +100,19 @@ export default async function SemesterPlanPage({

- + + + ({ id: s.id, date: s.date, diff --git a/src/app/[locale]/(app)/plan/actions.ts b/src/app/[locale]/(app)/plan/actions.ts index 3a048b9..44f5fdb 100644 --- a/src/app/[locale]/(app)/plan/actions.ts +++ b/src/app/[locale]/(app)/plan/actions.ts @@ -1,21 +1,29 @@ "use server" import { revalidatePath } from "next/cache" +import { eq } from "drizzle-orm" import { z } from "zod" import { db } from "@/db" import { semesterPlan, type PlanAvailability } from "@/db/schema" +import { actionError } from "@/lib/action-errors" import { requireSession } from "@/lib/auth/session" import { validateCron } from "@/lib/plan/absences" +import { markPlanStale } from "@/lib/plan/staleness" import { ownSemester } from "@/lib/studies/access" +import { recomputeSchedule } from "./schedule-actions" + +const HHMM = /^([01]\d|2[0-3]):[0-5]\d$/ const availabilitySchema = z.object({ weekly: z .array( - z.object({ - weekday: z.number().int().min(0).max(6), - from: z.string().regex(/^\d{2}:\d{2}$/), - to: z.string().regex(/^\d{2}:\d{2}$/), - }) + z + .object({ + weekday: z.number().int().min(0).max(6), + from: z.string().regex(HHMM), + to: z.string().regex(HHMM), + }) + .refine((w) => w.from < w.to, { message: "from must be before to" }) ) .max(21), blackouts: z @@ -29,28 +37,30 @@ const availabilitySchema = z.object({ .max(30), recurring: z .array( - z.object({ - weekday: z.number().int().min(0).max(6), - weekdays: z.array(z.number().int().min(0).max(6)).max(7).optional(), - from: z.string().regex(/^\d{2}:\d{2}$/), - to: z.string().regex(/^\d{2}:\d{2}$/), - interval: z.union([z.literal(1), z.literal(2)]), - anchor: z.string().date().optional(), - label: z.string().max(100).optional(), - cron: z - .string() - .max(100) - .optional() - .refine((v) => v == null || v === "" || validateCron(v) == null, { - message: "Invalid cron expression", - }), - durationMinutes: z - .number() - .int() - .min(5) - .max(24 * 60) - .optional(), - }) + z + .object({ + weekday: z.number().int().min(0).max(6), + weekdays: z.array(z.number().int().min(0).max(6)).max(7).optional(), + from: z.string().regex(HHMM), + to: z.string().regex(HHMM), + interval: z.union([z.literal(1), z.literal(2)]), + anchor: z.string().date().optional(), + label: z.string().max(100).optional(), + cron: z + .string() + .max(100) + .optional() + .refine((v) => v == null || v === "" || validateCron(v) == null, { + message: "Invalid cron expression", + }), + durationMinutes: z + .number() + .int() + .min(5) + .max(24 * 60) + .optional(), + }) + .refine((r) => r.from < r.to, { message: "from must be before to" }) ) .max(20) .optional(), @@ -64,6 +74,48 @@ export async function saveAvailability(semesterId: string, input: unknown) { .insert(semesterPlan) .values({ userId: session.user.id, semesterId, availability }) .onConflictDoUpdate({ target: semesterPlan.semesterId, set: { availability } }) + // Availability is a core scheduler input → auto-replan (best-effort). + try { + await recomputeSchedule(semesterId) + } catch { + // best-effort; availability was saved regardless + } + revalidatePath("/", "layout") + return { ok: true as const } +} + +const isoWeekSchema = z.string().regex(/^\d{4}-W(0[1-9]|[1-4]\d|5[0-3])$/) + +/** + * A9: sets ("this week I only have Nh") or clears (`hours == null`) a per-ISO-week + * study-hour cap on the semester plan. Marks the plan stale so the UI offers a + * recompute; the scheduler applies the cap on the next recompute. + */ +export async function saveWeekOverride( + semesterId: string, + isoWeek: string, + hours: number | null +) { + const session = await requireSession() + await ownSemester(semesterId, session.user.id) + const week = isoWeekSchema.parse(isoWeek) + const value = hours == null ? null : z.number().min(0).max(168).parse(hours) + + const plan = await db.query.semesterPlan.findFirst({ + where: eq(semesterPlan.semesterId, semesterId), + columns: { id: true, weekOverrides: true }, + }) + if (!plan) actionError("PLAN_NO_AVAILABILITY") + + const next = { ...(plan.weekOverrides ?? {}) } + if (value == null) delete next[week] + else next[week] = value + + await db + .update(semesterPlan) + .set({ weekOverrides: next }) + .where(eq(semesterPlan.id, plan.id)) + await markPlanStale(semesterId) revalidatePath("/", "layout") return { ok: true as const } } diff --git a/src/app/[locale]/(app)/plan/plan-task-actions.ts b/src/app/[locale]/(app)/plan/plan-task-actions.ts index bbad3c5..7c818fc 100644 --- a/src/app/[locale]/(app)/plan/plan-task-actions.ts +++ b/src/app/[locale]/(app)/plan/plan-task-actions.ts @@ -14,9 +14,13 @@ import { writingMilestone, writingProject, } from "@/db/schema" +import { actionError } from "@/lib/action-errors" import { requireSession } from "@/lib/auth/session" import { ownModule } from "@/lib/studies/access" import { buildTaskDrafts, sourceKey, type TaskGenInput } from "@/lib/plan/tasks" +import { dueDateField } from "@/lib/plan/task-validation" +import { markPlanStale } from "@/lib/plan/staleness" +import { toIsoDate } from "@/lib/events/recurrence" type OwnedModule = Awaited> @@ -26,6 +30,25 @@ function revalidateModule(mod: OwnedModule) { revalidatePath("/", "layout") } +/** A2: flag the module's semester plan stale after a plan-relevant mutation. */ +async function markStale(mod: OwnedModule) { + await markPlanStale(mod.semesterId) +} + +/** + * Verifies a client-supplied goalId belongs to the given (already-owned) + * module. Mirrors the check in ai/tool-executors.ts. Rejects cross-module + * goal references (IDOR). + */ +async function assertGoalInModule(moduleId: string, goalId: string | null | undefined) { + if (!goalId) return + const goals = await db.query.moduleGoal.findMany({ + where: eq(moduleGoal.moduleId, moduleId), + columns: { id: true }, + }) + if (!goals.some((g) => g.id === goalId)) actionError("PLAN_INVALID_GOAL") +} + /** Loads a plan task (with its module → semester → program) or throws. */ async function ownPlanTask(taskId: string, userId: string) { const row = await db.query.planTask.findFirst({ @@ -79,6 +102,7 @@ export async function updateModulePlanPrefs(moduleId: string, input: unknown) { ...(data.preferredWeekdays !== undefined ? { preferredWeekdays: data.preferredWeekdays } : {}), }) .where(eq(modulePlan.moduleId, moduleId)) + await markStale(mod) revalidateModule(mod) return { ok: true as const } } @@ -145,35 +169,79 @@ export async function generateModuleTasks(moduleId: string) { : [] const genInput: TaskGenInput = { goals, outlineTopics, assignments: openAssignments, milestones } - const drafts = buildTaskDrafts(genInput) + const today = toIsoDate(new Date()) + const drafts = buildTaskDrafts(genInput, today) await ensureModulePlan(moduleId) const existing = await db.query.planTask.findMany({ where: eq(planTask.moduleId, moduleId), - columns: { source: true, sortOrder: true }, + columns: { + id: true, + source: true, + sortOrder: true, + dueDate: true, + estimatedMinutes: true, + done: true, + }, }) const existingKeys = new Set(existing.map((t) => sourceKey(t.source))) + const draftByKey = new Map(drafts.map((d) => [sourceKey(d.source), d])) const maxSort = existing.reduce((m, t) => Math.max(m, t.sortOrder), -1) - const toInsert = drafts.filter((d) => !existingKeys.has(sourceKey(d.source))) - if (toInsert.length > 0) { - await db.insert(planTask).values( - toInsert.map((d, i) => ({ - moduleId, - goalId: d.goalId, - title: d.title, - description: d.description, - estimatedMinutes: d.estimatedMinutes, - dueDate: d.dueDate, - source: d.source, - sortOrder: maxSort + 1 + i, - aiGenerated: d.aiGenerated, - })) - ) + // A12 reconciliation: keep the generated task set in sync with the current + // draft set. Never touch manual tasks or anything already done. + const toDelete: string[] = [] + const toUpdate: { id: string; dueDate: string | null; estimatedMinutes: number }[] = [] + for (const t of existing) { + if (t.source.kind === "manual" || t.done) continue + const draft = draftByKey.get(sourceKey(t.source)) + if (!draft) { + toDelete.push(t.id) // generated task whose source vanished from the drafts + continue + } + if (t.dueDate !== draft.dueDate || t.estimatedMinutes !== draft.estimatedMinutes) { + toUpdate.push({ id: t.id, dueDate: draft.dueDate, estimatedMinutes: draft.estimatedMinutes }) + } } + + const toInsert = drafts.filter((d) => !existingKeys.has(sourceKey(d.source))) + + await db.transaction(async (tx) => { + if (toDelete.length > 0) { + await tx.delete(planTask).where(inArray(planTask.id, toDelete)) + } + for (const u of toUpdate) { + await tx + .update(planTask) + .set({ dueDate: u.dueDate, estimatedMinutes: u.estimatedMinutes }) + .where(eq(planTask.id, u.id)) + } + if (toInsert.length > 0) { + await tx.insert(planTask).values( + toInsert.map((d, i) => ({ + moduleId, + goalId: d.goalId, + title: d.title, + description: d.description, + estimatedMinutes: d.estimatedMinutes, + dueDate: d.dueDate, + source: d.source, + sortOrder: maxSort + 1 + i, + aiGenerated: d.aiGenerated, + })) + ) + } + }) + + await markStale(mod) revalidateModule(mod) - return { ok: true as const, created: toInsert.length } + return { + ok: true as const, + created: toInsert.length, + deleted: toDelete.length, + updated: toUpdate.length, + } } // ---- task CRUD -------------------------------------------------------------- @@ -182,7 +250,7 @@ const createSchema = z.object({ title: z.string().min(1).max(300), description: z.string().max(2000).optional().nullable(), estimatedMinutes: z.number().int().min(5).max(600).optional(), - dueDate: z.string().date().optional().nullable(), + dueDate: dueDateField.optional().nullable(), goalId: z.string().optional().nullable(), }) @@ -190,6 +258,7 @@ export async function createPlanTask(moduleId: string, input: unknown) { const session = await requireSession() const mod = await ownModule(moduleId, session.user.id) const data = createSchema.parse(input) + await assertGoalInModule(moduleId, data.goalId) await ensureModulePlan(moduleId) const existing = await db.query.planTask.findMany({ where: eq(planTask.moduleId, moduleId), @@ -209,6 +278,7 @@ export async function createPlanTask(moduleId: string, input: unknown) { sortOrder: maxSort + 1, }) .returning({ id: planTask.id }) + await markStale(mod) revalidateModule(mod) return { ok: true as const, id: created.id } } @@ -217,7 +287,7 @@ const updateSchema = z.object({ title: z.string().min(1).max(300).optional(), description: z.string().max(2000).optional().nullable(), estimatedMinutes: z.number().int().min(5).max(600).optional(), - dueDate: z.string().date().optional().nullable(), + dueDate: dueDateField.optional().nullable(), goalId: z.string().optional().nullable(), }) @@ -225,6 +295,7 @@ export async function updatePlanTask(taskId: string, input: unknown) { const session = await requireSession() const row = await ownPlanTask(taskId, session.user.id) const data = updateSchema.parse(input) + if (data.goalId !== undefined) await assertGoalInModule(row.moduleId, data.goalId) await db .update(planTask) .set({ @@ -235,6 +306,7 @@ export async function updatePlanTask(taskId: string, input: unknown) { ...(data.goalId !== undefined ? { goalId: data.goalId } : {}), }) .where(eq(planTask.id, taskId)) + await markStale(row.module) revalidateModule(row.module) return { ok: true as const } } @@ -243,6 +315,7 @@ export async function togglePlanTask(taskId: string, done: boolean) { const session = await requireSession() const row = await ownPlanTask(taskId, session.user.id) await db.update(planTask).set({ done }).where(eq(planTask.id, taskId)) + await markStale(row.module) revalidateModule(row.module) return { ok: true as const } } @@ -251,6 +324,7 @@ export async function deletePlanTask(taskId: string) { const session = await requireSession() const row = await ownPlanTask(taskId, session.user.id) await db.delete(planTask).where(eq(planTask.id, taskId)) + await markStale(row.module) revalidateModule(row.module) return { ok: true as const } } @@ -270,6 +344,7 @@ export async function reorderPlanTasks(moduleId: string, orderedIds: string[]) { await tx.update(planTask).set({ sortOrder: i }).where(eq(planTask.id, ids[i])) } }) + await markStale(mod) revalidateModule(mod) return { ok: true as const } } diff --git a/src/app/[locale]/(app)/plan/schedule-actions.ts b/src/app/[locale]/(app)/plan/schedule-actions.ts index bab5a46..1e19a59 100644 --- a/src/app/[locale]/(app)/plan/schedule-actions.ts +++ b/src/app/[locale]/(app)/plan/schedule-actions.ts @@ -4,21 +4,43 @@ import { revalidatePath } from "next/cache" import { and, eq, gte, inArray } from "drizzle-orm" import { z } from "zod" import { db } from "@/db" -import { modulePlan, planSession, planTask, semesterPlan, studyEvent } from "@/db/schema" +import { moduleGoal, modulePlan, planSession, planTask, semesterPlan, studyEvent } from "@/db/schema" +import type { PlanTaskSource } from "@/db/schema" +import { actionError } from "@/lib/action-errors" import { requireSession } from "@/lib/auth/session" import { ownSemester } from "@/lib/studies/access" import { expandAbsences } from "@/lib/plan/absences" import { expandOccurrences, toIsoDate } from "@/lib/events/recurrence" +import { reviewDays } from "@/lib/plan/tasks" +import { collectReplannableTasks } from "@/lib/plan/replan" import { computeSchedule, DEFAULT_SCHEDULE_CONFIG, type ScheduleInput, type ScheduleModuleInput, + type TaskCategory, } from "@/lib/plan/scheduler" const pad = (n: number) => String(n).padStart(2, "0") const hhmm = (d: Date) => `${pad(d.getHours())}:${pad(d.getMinutes())}` +/** UTC-based ISO day shift used for consolidation-window bounds. */ +function addDaysIso(date: string, n: number): string { + return new Date(Date.parse(`${date}T00:00:00Z`) + n * 86400000).toISOString().slice(0, 10) +} + +/** + * Derives a task's scheduling category from its generation source: the spaced + * review/mock drafts (`review-*`, `mock-*`) are `review`, the flashcard drafts + * (`cards-*`) are `cards`, everything else is `learn`. + */ +function categoryOf(source: PlanTaskSource): TaskCategory { + const ref = source.refId ?? "" + if (ref.startsWith("review-") || ref.startsWith("mock-")) return "review" + if (ref.startsWith("cards-")) return "cards" + return "learn" +} + /** * Recomputes the semester's study sessions from today forward with the * deterministic scheduler, then replaces future non-pinned, non-done sessions @@ -32,28 +54,83 @@ export async function recomputeSchedule(semesterId: string) { const plan = await db.query.semesterPlan.findFirst({ where: eq(semesterPlan.semesterId, semesterId), }) - if (!plan) throw new Error("No availability configured") + if (!plan) actionError("PLAN_NO_AVAILABILITY") const modules = await db.query.studyModule.findMany({ where: (m, { eq: e }) => e(m.semesterId, semesterId), columns: { id: true }, }) const moduleIds = modules.map((m) => m.id) - if (moduleIds.length === 0) return { ok: true as const, warnings: [] } + if (moduleIds.length === 0) { + await db + .update(semesterPlan) + .set({ staleAt: null, lastWarnings: [], generatedAt: new Date() }) + .where(eq(semesterPlan.id, plan.id)) + return { ok: true as const, warnings: [] } + } - const [plans, openTasks] = await Promise.all([ + const now = new Date() + const today = toIsoDate(now) + + const [plans, openTasks, examGoals, keptSessions] = await Promise.all([ db.query.modulePlan.findMany({ where: inArray(modulePlan.moduleId, moduleIds) }), db.query.planTask.findMany({ where: and(inArray(planTask.moduleId, moduleIds), eq(planTask.done, false)), orderBy: (t, { asc }) => [asc(t.sortOrder)], - columns: { id: true, moduleId: true, estimatedMinutes: true, dueDate: true }, + columns: { + id: true, + moduleId: true, + estimatedMinutes: true, + dueDate: true, + source: true, + sessionId: true, + }, + }), + db.query.moduleGoal.findMany({ + where: and( + inArray(moduleGoal.moduleId, moduleIds), + inArray(moduleGoal.type, ["exam", "oral_exam"]) + ), + columns: { moduleId: true, dueDate: true, config: true }, + }), + // Pinned / done future sessions are kept and treated as fixed blockers. + db.query.planSession.findMany({ + where: and(eq(planSession.semesterPlanId, plan.id), gte(planSession.date, today)), + columns: { id: true, date: true, startTime: true, durationMinutes: true, pinned: true, done: true }, }), ]) + // B3+A10: only replan tasks not frozen on a future pinned/done session; tasks + // on past undone sessions are pulled back in (catch-up). + const replannable = new Set( + collectReplannableTasks( + openTasks.map((t) => ({ id: t.id, sessionId: t.sessionId })), + keptSessions, + today + ) + ) + const scheduledTasks = openTasks.filter((t) => replannable.has(t.id)) + + // Per-module consolidation window from the module's nearest upcoming exam. + const consolidationByModule = new Map() + for (const g of examGoals) { + if (!g.dueDate || g.dueDate < today) continue + const existing = consolidationByModule.get(g.moduleId) + // Keep the earliest (nearest) exam per module. + if (existing && existing.end <= g.dueDate) continue + const rd = reviewDays(g.dueDate, today, g.config?.reviewDays) + consolidationByModule.set(g.moduleId, { start: addDaysIso(g.dueDate, -rd), end: g.dueDate }) + } + const tasksByModule = new Map() - for (const t of openTasks) { + for (const t of scheduledTasks) { const list = tasksByModule.get(t.moduleId) ?? [] - list.push({ id: t.id, estimatedMinutes: t.estimatedMinutes, dueDate: t.dueDate }) + list.push({ + id: t.id, + estimatedMinutes: t.estimatedMinutes, + dueDate: t.dueDate, + category: categoryOf(t.source), + }) tasksByModule.set(t.moduleId, list) } @@ -65,17 +142,21 @@ export async function recomputeSchedule(semesterId: string) { weeklyHoursTarget: p.weeklyHoursTarget == null ? null : Number(p.weeklyHoursTarget), phase: p.phase, preferredWeekdays: p.preferredWeekdays ?? null, + consolidation: consolidationByModule.get(p.moduleId) ?? null, tasks: tasksByModule.get(p.moduleId) ?? [], })) .filter((m) => m.tasks.length > 0) - // Horizon: at least 6 weeks, extended to the last task deadline if further. - const now = new Date() - const today = toIsoDate(now) + // Horizon: at least 6 weeks, extended to the last task deadline if further, + // but hard-capped at today+365d so a far-future due date can't blow up the + // scheduling window (DoS via distant deadlines). Tasks beyond the cap are + // simply clipped here (the scheduler emits horizon_clipped for them). const sixWeeks = new Date(now) sixWeeks.setDate(sixWeeks.getDate() + 42) + const cap = toIsoDate(new Date(now.getTime() + 365 * 86400000)) let horizonEnd = toIsoDate(sixWeeks) - for (const t of openTasks) if (t.dueDate && t.dueDate > horizonEnd) horizonEnd = t.dueDate + for (const t of scheduledTasks) if (t.dueDate && t.dueDate > horizonEnd) horizonEnd = t.dueDate + if (horizonEnd > cap) horizonEnd = cap const horizonDate = new Date(`${horizonEnd}T23:59:59`) // Absences (blackouts + recurring) → all-day / windowed blockers. @@ -111,11 +192,7 @@ export async function recomputeSchedule(semesterId: string) { } } - // Pinned / done future sessions are kept and treated as fixed blockers. - const keptSessions = await db.query.planSession.findMany({ - where: and(eq(planSession.semesterPlanId, plan.id), gte(planSession.date, today)), - columns: { id: true, date: true, startTime: true, durationMinutes: true, pinned: true, done: true }, - }) + // Kept future sessions (loaded above) are treated as fixed blockers. const pinnedSessions = keptSessions .filter((s) => s.pinned || s.done) .map((s) => ({ date: s.date, startTime: s.startTime, durationMinutes: s.durationMinutes })) @@ -125,6 +202,13 @@ export async function recomputeSchedule(semesterId: string) { sessionMinutes: plan.config?.sessionMinutes ?? DEFAULT_SCHEDULE_CONFIG.sessionMinutes, } + // Per-ISO-week hour overrides ({"2026-W31": 4}) → minute caps for the plan. + const weekCapacityOverrides = plan.weekOverrides + ? Object.fromEntries( + Object.entries(plan.weekOverrides).map(([week, hours]) => [week, Number(hours) * 60]) + ) + : undefined + const input: ScheduleInput = { today, horizonEnd, @@ -133,6 +217,7 @@ export async function recomputeSchedule(semesterId: string) { busyEvents, config: cfg, pinnedSessions, + weekCapacityOverrides, modules: scheduleModules, } @@ -158,6 +243,7 @@ export async function recomputeSchedule(semesterId: string) { date: s.date, startTime: s.startTime, durationMinutes: s.durationMinutes, + kind: s.kind, }) .returning({ id: planSession.id }) if (s.taskIds.length > 0) { @@ -169,7 +255,11 @@ export async function recomputeSchedule(semesterId: string) { } }) - await db.update(semesterPlan).set({ generatedAt: new Date() }).where(eq(semesterPlan.id, plan.id)) + // Plan is fresh again: clear staleness, persist warnings, stamp generatedAt. + await db + .update(semesterPlan) + .set({ generatedAt: new Date(), staleAt: null, lastWarnings: result.warnings }) + .where(eq(semesterPlan.id, plan.id)) revalidatePath("/", "layout") return { ok: true as const, warnings: result.warnings, sessions: result.sessions.length } } @@ -212,8 +302,20 @@ export async function moveSession(sessionId: string, input: unknown) { export async function toggleSession(sessionId: string, done: boolean) { const session = await requireSession() - await ownSession(sessionId, session.user.id) - await db.update(planSession).set({ done }).where(eq(planSession.id, sessionId)) + const row = await ownSession(sessionId, session.user.id) + // A8: session-done drives its tasks' done state so a replan never revives + // work the student already completed (and un-done restores them). + await db.transaction(async (tx) => { + await tx.update(planSession).set({ done }).where(eq(planSession.id, sessionId)) + await tx.update(planTask).set({ done }).where(eq(planTask.sessionId, sessionId)) + }) + // Auto-replan so the freed/blocked capacity is reflected. Never let a + // scheduling hiccup fail the toggle itself. + try { + await recomputeSchedule(row.semesterPlan.semesterId) + } catch { + // best-effort; the mutation above already succeeded + } revalidatePath("/", "layout") return { ok: true as const } } diff --git a/src/app/[locale]/(app)/quiz-actions.ts b/src/app/[locale]/(app)/quiz-actions.ts index 1ded3a4..742f4b9 100644 --- a/src/app/[locale]/(app)/quiz-actions.ts +++ b/src/app/[locale]/(app)/quiz-actions.ts @@ -4,10 +4,11 @@ import { GEN_PARAMS, GRADING_PARAMS } from "@/lib/ai/params" import { revalidatePath } from "next/cache" import { and, eq, isNull } from "drizzle-orm" import { generateObject } from "ai" -import { getLocale } from "next-intl/server" +import { getLocale, getTranslations } from "next-intl/server" import { z } from "zod" import { db } from "@/db" import { answerLog, question, quiz, quizAttempt } from "@/db/schema" +import { actionError } from "@/lib/action-errors" import { requireSession } from "@/lib/auth/session" import { getLanguageModel, resolveModelForUser } from "@/lib/ai/registry" import { searchChunks, getModuleMaterialSample } from "@/lib/ai/rag" @@ -230,7 +231,7 @@ export async function generateQuiz(input: unknown) { const moduleRow = data.moduleId ? await ownModule(data.moduleId, session.user.id) : null const defaultModel = await resolveModelForUser(session.user.id) - if (!defaultModel) throw new Error("No AI model configured") + if (!defaultModel) actionError("AI_NO_MODEL") const model = await getLanguageModel(defaultModel, session.user.id) const query = data.topics || moduleRow?.name || "key concepts" @@ -319,7 +320,13 @@ const submitSchema = z.object({ }) export type AttemptResult = { - score: number + /** Percentage over auto-gradable questions, or null when nothing could be + * graded (all free-text and no AI provider) — shown as "not graded". */ + score: number | null + /** How many new cards were added to the module's mistakes deck (E6). */ + mistakesAdded: number + /** The mistakes deck id (for a deep link), when cards were added. */ + mistakesDeckId: string | null results: { questionId: string prompt: string @@ -392,49 +399,55 @@ Reply with correct=true/false and one sentence of feedback in the language of th } } - const results: AttemptResult["results"] = [] - for (const { questionId, answer } of data.answers) { - const q = byId.get(questionId) - if (!q) continue - let correct = false - let graded = true - let feedback: string | null = null - if (q.kind === "multiple_choice") { - correct = q.correctIndex != null && Number(answer) === q.correctIndex - } else if (gradeFreeText && q.referenceAnswer) { - const result = await gradeFreeText(q.prompt, q.referenceAnswer, answer) - correct = result.correct - feedback = result.feedback - } else { - // No AI available: don't pretend to grade free text — the runner shows - // the reference answer and excludes the question from the score. - graded = false - } - const answerText = - q.kind === "multiple_choice" ? (q.options?.[Number(answer)] ?? answer) : answer - const correctAnswer = - q.kind === "multiple_choice" - ? q.correctIndex != null - ? (q.options?.[q.correctIndex] ?? null) - : null - : q.referenceAnswer - results.push({ - questionId, - prompt: q.prompt, - answer, - answerText, - correctAnswer, - correct, - graded, - feedback, - explanation: q.explanation, + // Grade all answers concurrently — the free-text AI calls are independent, so + // a sequential loop needlessly serialized N round-trips (E10). Question count + // is bounded (≤30), so a plain Promise.all is within provider limits. + const graded_ = await Promise.all( + data.answers.map(async ({ questionId, answer }) => { + const q = byId.get(questionId) + if (!q) return null + let correct = false + let graded = true + let feedback: string | null = null + if (q.kind === "multiple_choice") { + correct = q.correctIndex != null && Number(answer) === q.correctIndex + } else if (gradeFreeText && q.referenceAnswer) { + const result = await gradeFreeText(q.prompt, q.referenceAnswer, answer) + correct = result.correct + feedback = result.feedback + } else { + // No AI available: don't pretend to grade free text — the runner shows + // the reference answer and excludes the question from the score. + graded = false + } + const answerText = + q.kind === "multiple_choice" ? (q.options?.[Number(answer)] ?? answer) : answer + const correctAnswer = + q.kind === "multiple_choice" + ? q.correctIndex != null + ? (q.options?.[q.correctIndex] ?? null) + : null + : q.referenceAnswer + return { + questionId, + prompt: q.prompt, + answer, + answerText, + correctAnswer, + correct, + graded, + feedback, + explanation: q.explanation, + } }) - } + ) + const results: AttemptResult["results"] = graded_.filter((r) => r !== null) const gradedResults = results.filter((r) => r.graded) + // Nothing gradable (all free-text, no AI) → honest null instead of a fake 0%. const score = gradedResults.length === 0 - ? 0 + ? null : Math.round((gradedResults.filter((r) => r.correct).length / gradedResults.length) * 100) const [attempt] = await db @@ -442,7 +455,7 @@ Reply with correct=true/false and one sentence of feedback in the language of th .values({ quizId: data.quizId, userId: session.user.id, - score: String(score), + score: score != null ? String(score) : null, finishedAt: new Date(), }) .returning({ id: quizAttempt.id }) @@ -476,12 +489,16 @@ Reply with correct=true/false and one sentence of feedback in the language of th // Feed wrong answers into the module's auto-managed "mistakes" deck so they // come back through spaced repetition. const wrong = results.filter((r) => r.graded && !r.correct && r.correctAnswer) + let mistakesAdded = 0 + let mistakesDeckId: string | null = null if (wrong.length > 0) { - await addToMistakesDeck(session.user.id, quizRow?.moduleId ?? null, wrong) + const added = await addToMistakesDeck(session.user.id, quizRow?.moduleId ?? null, wrong) + mistakesAdded = added.added + mistakesDeckId = added.deckId } revalidatePath("/") - return { score, results } + return { score, results, mistakesAdded, mistakesDeckId } } /** @@ -493,7 +510,7 @@ async function addToMistakesDeck( userId: string, moduleId: string | null, wrong: { prompt: string; correctAnswer: string | null; explanation: string | null }[] -): Promise { +): Promise<{ added: number; deckId: string }> { const { deck, flashcard } = await import("@/db/schema") let mistakes = await db.query.deck.findFirst({ where: and( @@ -503,9 +520,13 @@ async function addToMistakesDeck( ), }) if (!mistakes) { + // Best-effort localized name at creation (the creator's current UI locale). + // The deck's identity is `kind === "mistakes"`, so the UI renders a + // localized label + badge regardless of this stored name (see deck-card). + const t = await getTranslations("learn.decks") ;[mistakes] = await db .insert(deck) - .values({ userId, moduleId, name: "Quiz-Fehler", kind: "mistakes" }) + .values({ userId, moduleId, name: t("mistakesName"), kind: "mistakes" }) .returning() } const existing = await db.query.flashcard.findMany({ @@ -523,4 +544,5 @@ async function addToMistakesDeck( })) ) } + return { added: fresh.length, deckId: mistakes.id } } diff --git a/src/app/[locale]/(app)/settings/actions.ts b/src/app/[locale]/(app)/settings/actions.ts index 4fc8229..d3fc064 100644 --- a/src/app/[locale]/(app)/settings/actions.ts +++ b/src/app/[locale]/(app)/settings/actions.ts @@ -90,6 +90,24 @@ export async function updatePreferredModel(ref: string) { return { ok: true as const } } +/** + * Persists the user's preferred locale (from the language switcher) so + * background reminders (push/email) are localized to match the UI. Falls back + * to the app default when the value isn't a supported locale. + */ +export async function updateLocalePref(locale: string) { + const session = await requireSession() + const { routing } = await import("@/i18n/routing") + if (!routing.locales.includes(locale as (typeof routing.locales)[number])) { + throw new Error("Unsupported locale") + } + await db + .insert(userPrefs) + .values({ userId: session.user.id, locale }) + .onConflictDoUpdate({ target: userPrefs.userId, set: { locale } }) + return { ok: true as const } +} + /** Sets the weekly study-time goal in minutes. null/0 clears it. */ export async function updateWeeklyGoal(minutes: number | null) { const session = await requireSession() diff --git a/src/app/[locale]/(app)/settings/page.tsx b/src/app/[locale]/(app)/settings/page.tsx index e78004a..c4bc4ce 100644 --- a/src/app/[locale]/(app)/settings/page.tsx +++ b/src/app/[locale]/(app)/settings/page.tsx @@ -19,6 +19,7 @@ import { Link } from "@/i18n/navigation" export default async function SettingsPage() { const session = await requireSession() const t = await getTranslations("settings") + const tf = await getTranslations("usage.features") // Calendar-month window — the SAME window the monthly token limit uses // (usage.ts assertWithinLimit), so the shown numbers match the enforcement. const monthStart = new Date() @@ -219,7 +220,9 @@ export default async function SettingsPage() {
    {featureRows.map((row) => (
  • - {row.feature} + + {tf.has(row.feature) ? tf(row.feature) : row.feature} + {row.tokens.toLocaleString()}
  • ))} diff --git a/src/app/[locale]/(app)/studies/[programId]/[moduleId]/assignments/page.tsx b/src/app/[locale]/(app)/studies/[programId]/[moduleId]/assignments/page.tsx index ed25d66..6e3f918 100644 --- a/src/app/[locale]/(app)/studies/[programId]/[moduleId]/assignments/page.tsx +++ b/src/app/[locale]/(app)/studies/[programId]/[moduleId]/assignments/page.tsx @@ -1,5 +1,5 @@ import { and, asc, desc, eq } from "drizzle-orm" -import { CalendarDays, FileText } from "lucide-react" +import { CalendarDays, ClipboardList, FileText } from "lucide-react" import { getFormatter, getTranslations } from "next-intl/server" import { db } from "@/db" import { assignment, material } from "@/db/schema" @@ -12,6 +12,7 @@ import { SubtaskList } from "@/components/learn/subtask-list" import { AiBadge } from "@/components/ai/ai-badge" import { DeleteButton } from "@/components/studies/delete-button" import { Badge } from "@/components/ui/badge" +import { EmptyState } from "@/components/ui/empty-state" const statusVariant = { open: "outline", submitted: "secondary", graded: "default" } as const @@ -50,7 +51,7 @@ export default async function ModuleAssignmentsPage({ {assignments.length === 0 ? ( -

    {t("empty")}

    + ) : (
      {assignments.map((a) => { diff --git a/src/app/[locale]/(app)/studies/[programId]/[moduleId]/chat/page.tsx b/src/app/[locale]/(app)/studies/[programId]/[moduleId]/chat/page.tsx index 933b1e6..867e39e 100644 --- a/src/app/[locale]/(app)/studies/[programId]/[moduleId]/chat/page.tsx +++ b/src/app/[locale]/(app)/studies/[programId]/[moduleId]/chat/page.tsx @@ -11,6 +11,7 @@ import { deleteConversation } from "@/app/[locale]/(app)/ai/actions" import { NewChatButton } from "@/components/ai/new-chat-button" import { DeleteButton } from "@/components/studies/delete-button" import { Card, CardContent } from "@/components/ui/card" +import { EmptyState } from "@/components/ui/empty-state" export default async function ModuleChatPage({ params, @@ -59,11 +60,7 @@ export default async function ModuleChatPage({ ) : conversations.length === 0 ? ( - - - {t("noConversations")} - - + ) : (
        {conversations.map((c) => ( diff --git a/src/app/[locale]/(app)/studies/[programId]/[moduleId]/decks/[deckId]/study/page.tsx b/src/app/[locale]/(app)/studies/[programId]/[moduleId]/decks/[deckId]/study/page.tsx index daf1c12..219abac 100644 --- a/src/app/[locale]/(app)/studies/[programId]/[moduleId]/decks/[deckId]/study/page.tsx +++ b/src/app/[locale]/(app)/studies/[programId]/[moduleId]/decks/[deckId]/study/page.tsx @@ -28,7 +28,26 @@ export default async function ModuleStudyPage({ }) if (!deckRow || deckRow.moduleId !== moduleId) notFound() - const base = { columns: { id: true, front: true, back: true } as const, limit: count } + // Include the FSRS scheduling fields so StudySession can render the + // client-side next-interval preview on the rating buttons (E8). + const base = { + columns: { + id: true, + front: true, + back: true, + due: true, + stability: true, + difficulty: true, + elapsedDays: true, + scheduledDays: true, + learningSteps: true, + reps: true, + lapses: true, + state: true, + lastReview: true, + } as const, + limit: count, + } const cards = await (() => { switch (mode) { case "order": @@ -64,8 +83,9 @@ export default async function ModuleStudyPage({ } })() - // Always render StudySession — the empty state lives inside it so that the - // completion screen survives the path revalidation triggered by reviewCard. + // Always render StudySession — the empty state and completion screen live + // inside it and are driven entirely by client state, so the session survives + // re-renders and reviewCard's dashboard revalidation never unmounts them. return ( {decks.length === 0 ? ( -

        {t("empty")}

        + ) : (
        {decks.map((d) => ( @@ -50,6 +52,7 @@ export default async function ModuleDecksPage({ name: d.name, description: d.description, aiGenerated: d.aiGenerated, + kind: d.kind, cardCount: d.cards.length, dueCount: d.cards.filter((c) => c.due <= now).length, }} diff --git a/src/app/[locale]/(app)/studies/[programId]/[moduleId]/materials/page.tsx b/src/app/[locale]/(app)/studies/[programId]/[moduleId]/materials/page.tsx index 972219b..e4130b2 100644 --- a/src/app/[locale]/(app)/studies/[programId]/[moduleId]/materials/page.tsx +++ b/src/app/[locale]/(app)/studies/[programId]/[moduleId]/materials/page.tsx @@ -42,6 +42,8 @@ export default async function ModuleMaterialsPage({ createdAt: m.createdAt.toISOString(), extractionStatus: m.extractionStatus, extractionError: m.extractionError, + chunksTotal: m.chunksTotal, + chunksEmbedded: m.chunksEmbedded, }))} folders={folders.map((f) => ({ id: f.id, parentId: f.parentId, name: f.name }))} /> diff --git a/src/app/[locale]/(app)/studies/[programId]/[moduleId]/page.tsx b/src/app/[locale]/(app)/studies/[programId]/[moduleId]/page.tsx index 400b9bc..0e9ac40 100644 --- a/src/app/[locale]/(app)/studies/[programId]/[moduleId]/page.tsx +++ b/src/app/[locale]/(app)/studies/[programId]/[moduleId]/page.tsx @@ -1,12 +1,21 @@ import { notFound } from "next/navigation" -import { asc, eq } from "drizzle-orm" +import { and, asc, eq } from "drizzle-orm" import { ExternalLink, KeyRound } from "lucide-react" import { getTranslations } from "next-intl/server" import { db } from "@/db" -import { assignment, externalResource, moduleContact, studyModule } from "@/db/schema" +import { + assignment, + externalResource, + moduleContact, + planTask, + semesterPlan, + studyModule, +} from "@/db/schema" import { requireSession } from "@/lib/auth/session" import { isAiAvailable } from "@/lib/ai/registry" import { decrypt } from "@/lib/crypto" +import { expandAbsences } from "@/lib/plan/absences" +import { capacityMinutes, computeReadiness } from "@/lib/plan/readiness" import { gradeGoalResult } from "@/lib/grades" import { getModuleFinalGrade } from "@/lib/studies/grades-server" import { getModuleStats } from "@/lib/learning/stats-server" @@ -17,6 +26,7 @@ import { AnalyzeButton } from "@/components/learn/analyze-button" import type { GoalCardData } from "@/components/learn/goal-card" import { ModuleGradeSummaryCard } from "@/components/learn/module-grade-summary-card" import { ModuleContactsCard } from "@/components/learn/module-contacts-card" +import type { GoalReadinessDTO } from "@/components/learn/goal-card" import { ModuleGoalsCard } from "@/components/studies/module-goals-card" import { SessionStartDialog } from "@/components/learn/session-start-dialog" import { Badge } from "@/components/ui/badge" @@ -115,6 +125,48 @@ export default async function ModuleOverviewPage({ const hasBonusGoal = mod.goals.some((g) => g.gradingRole === "bonus") const readinessStats = { dueCards: stats.dueCards, lastQuizScore: stats.lastQuizScore } + // A7: traffic-light readiness for the module's nearest upcoming exam — open + // task minutes vs. study capacity until the exam. null → no plan yet (the + // goal card shows a neutral "set up your plan" hint instead of a light). + const examGoal = + mod.goals + .filter((g) => (g.type === "exam" || g.type === "oral_exam") && g.dueDate) + .sort((a, b) => (a.dueDate! < b.dueDate! ? -1 : 1))[0] ?? null + const todayIso = new Date().toISOString().slice(0, 10) + let readiness: GoalReadinessDTO = null + if (examGoal?.dueDate && examGoal.dueDate > todayIso) { + const [semPlan, openTasks] = await Promise.all([ + db.query.semesterPlan.findFirst({ + where: eq(semesterPlan.semesterId, mod.semesterId), + columns: { availability: true, lastWarnings: true }, + }), + db.query.planTask.findMany({ + where: and(eq(planTask.moduleId, moduleId), eq(planTask.done, false)), + columns: { estimatedMinutes: true }, + }), + ]) + if (semPlan && (semPlan.availability.weekly?.length ?? 0) > 0) { + const remainingMinutes = openTasks.reduce((sum, t) => sum + t.estimatedMinutes, 0) + const blocked = expandAbsences( + semPlan.availability, + new Date(`${todayIso}T00:00:00`), + new Date(`${examGoal.dueDate}T00:00:00`) + ).map((w) => ({ date: w.date, from: w.from, to: w.to })) + const capacity = capacityMinutes({ + today: todayIso, + examDate: examGoal.dueDate, + weekly: semPlan.availability.weekly, + blocked, + }) + readiness = { + status: computeReadiness({ remainingMinutes, capacityMinutes: capacity }), + warningKinds: (semPlan.lastWarnings ?? []) + .filter((w) => w.moduleId == null || w.moduleId === moduleId) + .map((w) => w.kind), + } + } + } + const goalCards: GoalCardData[] = mod.goals.map((g) => { const attempts = g.attempts.map((a) => ({ id: a.id, @@ -206,6 +258,7 @@ export default async function ModuleOverviewPage({ gradingSystem={gradingSystem} stats={readinessStats} bonus={finalGrade?.bonus ?? null} + readiness={readiness} /> {finalGrade && diff --git a/src/app/[locale]/(app)/studies/[programId]/[moduleId]/plan/page.tsx b/src/app/[locale]/(app)/studies/[programId]/[moduleId]/plan/page.tsx index 04127bd..3f2498d 100644 --- a/src/app/[locale]/(app)/studies/[programId]/[moduleId]/plan/page.tsx +++ b/src/app/[locale]/(app)/studies/[programId]/[moduleId]/plan/page.tsx @@ -1,24 +1,26 @@ import { and, asc, eq, gte } from "drizzle-orm" import { db } from "@/db" -import { moduleGoal, planSession, planTask } from "@/db/schema" +import { moduleGoal, moduleOutline, outlineTopic, planSession, planTask, semesterPlan } from "@/db/schema" import { requireSession } from "@/lib/auth/session" import { ownModule } from "@/lib/studies/access" import { ensureModulePlan } from "@/app/[locale]/(app)/plan/plan-task-actions" import { ModulePlanView } from "@/components/plan/module-plan-view" +import type { SetupStep } from "@/components/plan/setup-checklist" export default async function ModulePlanPage({ params, }: { params: Promise<{ programId: string; moduleId: string }> }) { - const { moduleId } = await params + const { programId, moduleId } = await params const session = await requireSession() const mod = await ownModule(moduleId, session.user.id) + const basePath = `/studies/${programId}/${moduleId}` const modulePlan = await ensureModulePlan(moduleId) const today = new Date().toISOString().slice(0, 10) - const [tasks, goals, sessions] = await Promise.all([ + const [tasks, goals, sessions, plan, outline] = await Promise.all([ db.query.planTask.findMany({ where: eq(planTask.moduleId, moduleId), orderBy: [asc(planTask.sortOrder)], @@ -26,19 +28,51 @@ export default async function ModulePlanPage({ db.query.moduleGoal.findMany({ where: eq(moduleGoal.moduleId, moduleId), orderBy: [asc(moduleGoal.sortOrder)], - columns: { id: true, type: true, title: true }, + columns: { id: true, type: true, title: true, dueDate: true }, }), db.query.planSession.findMany({ where: and(eq(planSession.moduleId, moduleId), gte(planSession.date, today)), orderBy: [asc(planSession.date), asc(planSession.startTime)], with: { tasks: { columns: { id: true, title: true, done: true } } }, }), + db.query.semesterPlan.findFirst({ + where: eq(semesterPlan.semesterId, mod.semesterId), + columns: { availability: true, generatedAt: true }, + }), + db.query.moduleOutline.findFirst({ + where: eq(moduleOutline.moduleId, moduleId), + columns: { version: true }, + }), ]) + const outlineTopicCount = outline + ? ( + await db.query.outlineTopic.findMany({ + where: and(eq(outlineTopic.moduleId, moduleId), eq(outlineTopic.version, outline.version)), + columns: { id: true }, + }) + ).length + : 0 + + const examGoals = goals.filter((g) => g.type === "exam" || g.type === "oral_exam") + const hasExamGoal = examGoals.length > 0 + const hasAvailability = (plan?.availability.weekly?.length ?? 0) > 0 + const hasPlan = sessions.length > 0 || plan?.generatedAt != null + + // Module-scoped setup checklist: the exam date, availability, tasks and the + // computed plan. (The module itself already exists on this page.) + const setupSteps: SetupStep[] = [ + { key: "examDate", done: examGoals.some((g) => g.dueDate != null), href: basePath }, + { key: "availability", done: hasAvailability, href: `/plan/${mod.semesterId}` }, + { key: "tasks", done: tasks.length > 0, href: `${basePath}/plan` }, + { key: "plan", done: hasPlan, href: `/plan/${mod.semesterId}` }, + ] + return ( 0} + hasExamGoal={hasExamGoal} + hasOutline={outlineTopicCount > 0} goals={goals.map((g) => ({ id: g.id, type: g.type, title: g.title }))} tasks={tasks.map((t) => ({ id: t.id, @@ -58,6 +94,7 @@ export default async function ModulePlanPage({ goalId: t.goalId, done: t.done, scheduled: t.sessionId != null, + aiGenerated: t.aiGenerated, }))} sessions={sessions.map((s) => ({ id: s.id, @@ -67,6 +104,7 @@ export default async function ModulePlanPage({ done: s.done, taskCount: s.tasks.length, }))} + setupSteps={setupSteps} /> ) } diff --git a/src/app/[locale]/(app)/studies/[programId]/[moduleId]/quizzes/[quizId]/page.tsx b/src/app/[locale]/(app)/studies/[programId]/[moduleId]/quizzes/[quizId]/page.tsx index 3042afb..024fc92 100644 --- a/src/app/[locale]/(app)/studies/[programId]/[moduleId]/quizzes/[quizId]/page.tsx +++ b/src/app/[locale]/(app)/studies/[programId]/[moduleId]/quizzes/[quizId]/page.tsx @@ -1,4 +1,4 @@ -import { notFound } from "next/navigation" +import { notFound, redirect } from "next/navigation" import { and, asc, desc, eq } from "drizzle-orm" import { getFormatter, getTranslations } from "next-intl/server" import { db } from "@/db" @@ -40,9 +40,10 @@ export default async function ModuleQuizDetailPage({ }) const wrongIds = latest?.answers.filter((a) => a.correct === false).map((a) => a.questionId) ?? [] - if (wrongIds.length > 0) { - selectedQuestions = quizRow.questions.filter((q) => wrongIds.includes(q.id)) - } + // No wrong answers to retry → don't silently fall back to the full quiz + // (E11); send the user back to the quiz overview. + if (wrongIds.length === 0) redirect(`${basePath}/quizzes/${quizId}`) + selectedQuestions = quizRow.questions.filter((q) => wrongIds.includes(q.id)) } const runnerQuestions: RunnerQuestion[] = selectedQuestions.map((q) => ({ id: q.id, @@ -50,16 +51,19 @@ export default async function ModuleQuizDetailPage({ prompt: q.prompt, options: q.options, })) - return + return } const attempts = await db.query.quizAttempt.findMany({ where: and(eq(quizAttempt.quizId, quizId), eq(quizAttempt.userId, session.user.id)), orderBy: [desc(quizAttempt.startedAt)], limit: 20, + with: { answers: { columns: { correct: true } } }, }) const finished = attempts.filter((a) => a.finishedAt) - const hasWrong = finished.length > 0 + // Only offer "retry wrong" when the latest finished attempt actually has + // wrong answers (E11) — otherwise retry would just replay the whole quiz. + const hasWrong = finished[0]?.answers.some((a) => a.correct === false) ?? false return (
        @@ -113,7 +117,9 @@ export default async function ModuleQuizDetailPage({ key={a.id} className="flex items-center justify-between rounded-md border px-3 py-2 text-sm" > - {Number(a.score ?? 0)}% + + {a.score != null ? `${Number(a.score)}%` : t("notScored")} + {format.dateTime(a.startedAt, { dateStyle: "medium", timeStyle: "short" })} diff --git a/src/app/[locale]/(app)/studies/[programId]/[moduleId]/quizzes/page.tsx b/src/app/[locale]/(app)/studies/[programId]/[moduleId]/quizzes/page.tsx index 45c0f09..97c1579 100644 --- a/src/app/[locale]/(app)/studies/[programId]/[moduleId]/quizzes/page.tsx +++ b/src/app/[locale]/(app)/studies/[programId]/[moduleId]/quizzes/page.tsx @@ -9,6 +9,8 @@ import { ownModule } from "@/lib/studies/access" import { getModuleColorClasses, getModuleIcon } from "@/lib/module-visuals" import { QuizCard } from "@/components/learn/quiz-card" import { GenerateQuizDialog, QuizDialog } from "@/components/learn/quiz-dialogs" +import { EmptyState } from "@/components/ui/empty-state" +import { ListChecks } from "lucide-react" export default async function ModuleQuizzesPage({ params, @@ -45,7 +47,7 @@ export default async function ModuleQuizzesPage({ />
        {quizzes.length === 0 ? ( -

        {t("empty")}

        + ) : (
        {quizzes.map((q) => { diff --git a/src/app/[locale]/(app)/studies/actions.ts b/src/app/[locale]/(app)/studies/actions.ts index dae5f00..c3bcd0b 100644 --- a/src/app/[locale]/(app)/studies/actions.ts +++ b/src/app/[locale]/(app)/studies/actions.ts @@ -14,6 +14,7 @@ import { import { requireSession } from "@/lib/auth/session" import { encrypt } from "@/lib/crypto" import { ownModule, ownProgram, ownSemester } from "@/lib/studies/access" +import { sweepModuleFiles, sweepProgramFiles, sweepSemesterFiles } from "@/lib/studies/orphan-cleanup" // ---- Degree programs ------------------------------------------------------- @@ -83,6 +84,9 @@ export async function updateProgram(programId: string, input: unknown) { export async function deleteProgram(programId: string) { const session = await requireSession() await ownProgram(programId, session.user.id) + // The FK cascade removes the material rows but not their storage blobs — sweep + // them first (collect paths → background delete) so files aren't orphaned. + await sweepProgramFiles(programId) await db.delete(degreeProgram).where(eq(degreeProgram.id, programId)) revalidatePath("/studies") return { ok: true as const } @@ -117,6 +121,7 @@ export async function updateSemester(semesterId: string, input: unknown) { export async function deleteSemester(semesterId: string) { const session = await requireSession() const sem = await ownSemester(semesterId, session.user.id) + await sweepSemesterFiles(semesterId) await db.delete(semester).where(eq(semester.id, semesterId)) revalidatePath(`/studies/${sem.programId}`) return { ok: true as const } @@ -170,16 +175,25 @@ export async function createModule(semesterId: string, input: unknown) { const session = await requireSession() const sem = await ownSemester(semesterId, session.user.id) const data = createModuleSchema.parse(input) - const [created] = await db - .insert(studyModule) - .values({ ...moduleValues(data), semesterId }) - .returning({ id: studyModule.id }) - // Seed one goal per selected type (deduped); modules created without a - // selection keep a single default exam goal so they are immediately gradable. + // Module insert + goal seeding must be atomic: a module with no goals breaks + // grade/tab derivation, so a failed goal insert must roll back the module too. const types = data.goalTypes?.length ? [...new Set(data.goalTypes)] : ["exam" as const] - await db.insert(moduleGoal).values( - types.map((type, i) => ({ moduleId: created.id, type, gradingRole: "grade" as const, sortOrder: i })) - ) + await db.transaction(async (tx) => { + const [created] = await tx + .insert(studyModule) + .values({ ...moduleValues(data), semesterId }) + .returning({ id: studyModule.id }) + // Seed one goal per selected type (deduped); modules created without a + // selection keep a single default exam goal so they are immediately gradable. + await tx.insert(moduleGoal).values( + types.map((type, i) => ({ + moduleId: created.id, + type, + gradingRole: "grade" as const, + sortOrder: i, + })) + ) + }) revalidatePath(`/studies/${sem.programId}`) return { ok: true as const } } @@ -204,6 +218,7 @@ export async function updateModule(moduleId: string, input: unknown) { export async function deleteModule(moduleId: string) { const session = await requireSession() const mod = await ownModule(moduleId, session.user.id) + await sweepModuleFiles(moduleId) await db.delete(studyModule).where(eq(studyModule.id, moduleId)) revalidatePath(`/studies/${mod.semester.programId}`) return { ok: true as const } diff --git a/src/app/[locale]/(app)/studies/goal-actions.ts b/src/app/[locale]/(app)/studies/goal-actions.ts index 1392c4a..0f930ed 100644 --- a/src/app/[locale]/(app)/studies/goal-actions.ts +++ b/src/app/[locale]/(app)/studies/goal-actions.ts @@ -4,9 +4,59 @@ import { revalidatePath } from "next/cache" import { asc, eq } from "drizzle-orm" import { z } from "zod" import { db } from "@/db" -import { goalAttempt, moduleGoal } from "@/db/schema" +import { goalAttempt, moduleGoal, studyEvent } from "@/db/schema" +import { actionError } from "@/lib/action-errors" import { requireSession } from "@/lib/auth/session" import { ownModule } from "@/lib/studies/access" +import { markPlanStale } from "@/lib/plan/staleness" +import { recomputeSchedule } from "../plan/schedule-actions" + +const EXAM_TYPES = new Set(["exam", "oral_exam"]) + +/** + * A3: keeps an all-day calendar event in sync with an exam/oral_exam goal's + * date, keyed idempotently on `goal_id`. The scheduler treats all-day events as + * whole-day blockers → the exam day is blocked. Non-exam goals, or exams + * without a date, have their (any) event removed. + */ +async function syncExamEvent(opts: { + userId: string + goalId: string + type: string + dueDate: string | null + title: string | null + moduleName: string +}) { + const existing = await db.query.studyEvent.findFirst({ + where: eq(studyEvent.goalId, opts.goalId), + columns: { id: true }, + }) + if (!EXAM_TYPES.has(opts.type) || !opts.dueDate) { + if (existing) await db.delete(studyEvent).where(eq(studyEvent.goalId, opts.goalId)) + return + } + const title = opts.title?.trim() || opts.moduleName + const startsAt = new Date(`${opts.dueDate}T00:00:00`) // local midnight → all-day + if (existing) { + await db + .update(studyEvent) + .set({ type: "exam", title, startsAt, allDay: true }) + .where(eq(studyEvent.id, existing.id)) + } else { + await db + .insert(studyEvent) + .values({ userId: opts.userId, goalId: opts.goalId, type: "exam", title, startsAt, allDay: true }) + } +} + +/** Best-effort auto-replan after an exam date changed (never fails the caller). */ +async function autoReplan(semesterId: string) { + try { + await recomputeSchedule(semesterId) + } catch { + // best-effort; the goal mutation already succeeded + } +} const attemptSchema = z.object({ resultPercent: z.number().min(0).max(100).optional().nullable(), @@ -99,7 +149,21 @@ export async function createGoal(moduleId: string, input: unknown) { .insert(moduleGoal) .values({ ...goalValues(data), moduleId, sortOrder: nextOrder }) .returning({ id: moduleGoal.id }) + // A3 exam-event sync + A2 staleness/auto-replan when a dated exam is added. + await syncExamEvent({ + userId: session.user.id, + goalId: created.id, + type: data.type, + dueDate: data.dueDate ?? null, + title: data.title ?? null, + moduleName: mod.name, + }) + if (EXAM_TYPES.has(data.type) && data.dueDate) { + await markPlanStale(mod.semesterId) + await autoReplan(mod.semesterId) + } revalidatePath(`/studies/${mod.semester.programId}/${moduleId}`) + revalidatePath("/", "layout") return { ok: true as const, id: created.id } } @@ -107,16 +171,42 @@ export async function updateGoal(goalId: string, input: unknown) { const session = await requireSession() const row = await ownGoal(goalId, session.user.id) const data = goalSchema.parse(input) + const oldDue = row.dueDate + const newDue = data.dueDate ?? null + const wasExam = EXAM_TYPES.has(row.type) + const isExam = EXAM_TYPES.has(data.type) await db.update(moduleGoal).set(goalValues(data)).where(eq(moduleGoal.id, goalId)) + // A3 exam-event sync (keyed on goal_id). + await syncExamEvent({ + userId: session.user.id, + goalId, + type: data.type, + dueDate: newDue, + title: data.title ?? null, + moduleName: row.module.name, + }) + // A2: an exam window opened/closed/moved → stale + auto-replan. + if ((wasExam || isExam) && (oldDue !== newDue || wasExam !== isExam)) { + await markPlanStale(row.module.semesterId) + await autoReplan(row.module.semesterId) + } revalidatePath(`/studies/${row.module.semester.programId}/${row.moduleId}`) + revalidatePath("/", "layout") return { ok: true as const } } export async function deleteGoal(goalId: string) { const session = await requireSession() const row = await ownGoal(goalId, session.user.id) + const wasDatedExam = EXAM_TYPES.has(row.type) && row.dueDate != null + // The exam event is removed automatically (event.goal_id ON DELETE CASCADE). await db.delete(moduleGoal).where(eq(moduleGoal.id, goalId)) + if (wasDatedExam) { + await markPlanStale(row.module.semesterId) + await autoReplan(row.module.semesterId) + } revalidatePath(`/studies/${row.module.semester.programId}/${row.moduleId}`) + revalidatePath("/", "layout") return { ok: true as const } } @@ -166,7 +256,7 @@ export async function addAttempt(goalId: string, input: unknown) { columns: { attempt: true }, }) if (existing.length >= goal.maxAttempts) { - throw new Error("Maximale Versuchszahl erreicht") + actionError("GOAL_MAX_ATTEMPTS") } const nextAttempt = existing.reduce((max, a) => Math.max(max, a.attempt), 0) + 1 diff --git a/src/app/[locale]/(app)/studies/writing-actions.ts b/src/app/[locale]/(app)/studies/writing-actions.ts index 8431123..00774b7 100644 --- a/src/app/[locale]/(app)/studies/writing-actions.ts +++ b/src/app/[locale]/(app)/studies/writing-actions.ts @@ -8,9 +8,15 @@ import { db } from "@/db" import { moduleGoal, studyEvent, writingMilestone, writingProject } from "@/db/schema" import type { GoalType } from "@/db/schema/studies" import type { WritingPhase } from "@/db/schema/thesis" +import { actionError } from "@/lib/action-errors" import { requireSession } from "@/lib/auth/session" import { ownModule } from "@/lib/studies/access" -import { getLanguageModel, resolveModelForUser } from "@/lib/ai/registry" +import { + getLanguageModel, + resolveModelForUser, + userHasUsableKeyForModel, +} from "@/lib/ai/registry" +import { GEN_PARAMS, maxTokensForItems, maxTokensForText } from "@/lib/ai/params" import { assertWithinLimit } from "@/lib/ai/usage" import { runAi } from "@/lib/ai/run" import { searchChunks } from "@/lib/ai/rag" @@ -29,7 +35,11 @@ const WRITING_GOAL_TYPES: readonly GoalType[] = ["thesis", "term_paper", "projec async function getModel(userId: string) { const defaultModel = await resolveModelForUser(userId) - if (!defaultModel) throw new Error("No AI model configured") + if (!defaultModel) actionError("AI_NO_MODEL") + // BYOK dead-end: a model is configured but the user has no usable key for its + // provider — surface an actionable "add your key in Settings" error instead of + // letting the request fail deep inside the SDK with a generic auth error (F4). + if (!(await userHasUsableKeyForModel(defaultModel, userId))) actionError("AI_SETUP_REQUIRED") return { ref: defaultModel, model: await getLanguageModel(defaultModel, userId) } } @@ -96,7 +106,7 @@ export async function ensureModuleWritingProject(moduleId: string) { (g): g is (typeof goals)[number] => Boolean(g) ) if (!goal) { - throw new Error("Dieses Modul hat kein Schreib-Lernziel (Hausarbeit, Thesis oder Projekt).") + actionError("WRITING_NO_GOAL") } const existing = await db.query.writingProject.findFirst({ @@ -130,6 +140,10 @@ export async function ensureModuleWritingProject(moduleId: string) { } const variant = isThesis ? "scientific" : goal.config.variant ?? "scientific" + // Concurrent first-touches of the same goal would both pass the `existing` + // check above and double-insert, tripping `writing_active_per_goal_uq` (or the + // thesis-per-program index). `onConflictDoNothing` makes the loser a no-op; we + // then re-select the row the winner created and return that. const [created] = await db .insert(writingProject) .values({ @@ -144,9 +158,42 @@ export async function ensureModuleWritingProject(moduleId: string) { semesterId: mod.semesterId, dueDate: goal.dueDate ?? null, }) + .onConflictDoNothing() .returning({ id: writingProject.id }) - revalidateWriting(await ownWriting(created.id, session.user.id)) - return created.id + if (created) { + revalidateWriting(await ownWriting(created.id, session.user.id)) + return created.id + } + + // Lost the race — the live row already exists. Both code paths set + // `goalId = goal.id`, so re-selecting the live row by goal resolves either a + // term-paper or a thesis conflict. + const raced = await db.query.writingProject.findFirst({ + where: and(eq(writingProject.goalId, goal.id), isNull(writingProject.supersededById)), + columns: { id: true }, + }) + if (raced) return raced.id + // Thesis conflict where the winning row hasn't been goal-linked yet: fall back + // to the program-scoped live thesis and link it. + if (isThesis) { + const liveThesis = await db.query.writingProject.findFirst({ + where: and( + eq(writingProject.userId, session.user.id), + eq(writingProject.programId, mod.semester.programId), + eq(writingProject.kind, "thesis"), + isNull(writingProject.supersededById) + ), + columns: { id: true }, + }) + if (liveThesis) { + await db + .update(writingProject) + .set({ goalId: goal.id }) + .where(eq(writingProject.id, liveThesis.id)) + return liveThesis.id + } + } + throw new Error("Not found") } // ---- Project CRUD ----------------------------------------------------------- @@ -284,7 +331,13 @@ export async function generateWritingOutline(projectId: string) { entityId: projectId, entityLabel: row.title, }, - () => generateText({ model, prompt: buildOutlinePrompt(promptFields(row), grounding) }) + () => + generateText({ + model, + prompt: buildOutlinePrompt(promptFields(row), grounding), + ...GEN_PARAMS, + maxOutputTokens: maxTokensForText(), + }) ) await db.update(writingProject).set({ outline: text }).where(eq(writingProject.id, projectId)) revalidateWriting(row) @@ -295,7 +348,7 @@ export async function generateWritingMilestones(projectId: string, addToCalendar const session = await requireSession() await assertWithinLimit(session.user.id) const row = await ownWriting(projectId, session.user.id) - if (!row.dueDate) throw new Error("Set a due date first") + if (!row.dueDate) actionError("WRITING_NO_DUE_DATE") const { ref, model } = await getModel(session.user.id) const today = new Date().toISOString().slice(0, 10) @@ -313,6 +366,8 @@ export async function generateWritingMilestones(projectId: string, addToCalendar model, schema: milestonesSchema, prompt: buildMilestonesPrompt(promptFields(row), today), + ...GEN_PARAMS, + maxOutputTokens: maxTokensForItems(15), }) ) @@ -362,6 +417,8 @@ export async function suggestWritingSources(projectId: string) { model, schema: sourcesSchema, prompt: buildSourcesPrompt(promptFields(row)), + ...GEN_PARAMS, + maxOutputTokens: maxTokensForItems(20), }) ) return object diff --git a/src/app/[locale]/(app)/study/due/page.tsx b/src/app/[locale]/(app)/study/due/page.tsx index 247f75b..1deff5a 100644 --- a/src/app/[locale]/(app)/study/due/page.tsx +++ b/src/app/[locale]/(app)/study/due/page.tsx @@ -20,7 +20,22 @@ export default async function DueStudyPage() { const [cards, totalRows] = deckIds.length ? await Promise.all([ db.query.flashcard.findMany({ - columns: { id: true, front: true, back: true }, + // FSRS fields included for the rating-button interval preview (E8). + columns: { + id: true, + front: true, + back: true, + due: true, + stability: true, + difficulty: true, + elapsedDays: true, + scheduledDays: true, + learningSteps: true, + reps: true, + lapses: true, + state: true, + lastReview: true, + }, where: (f, { and: a }) => a(inArray(f.deckId, deckIds), lte(f.due, now)), orderBy: [asc(flashcard.due)], limit: SESSION_LIMIT, diff --git a/src/app/[locale]/(app)/thesis/actions.ts b/src/app/[locale]/(app)/thesis/actions.ts index e92d84d..913a2e4 100644 --- a/src/app/[locale]/(app)/thesis/actions.ts +++ b/src/app/[locale]/(app)/thesis/actions.ts @@ -6,9 +6,15 @@ import { generateObject } from "ai" import { z } from "zod" import { db } from "@/db" import { writingProject } from "@/db/schema" +import { actionError } from "@/lib/action-errors" import { requireSession } from "@/lib/auth/session" import { ownProgram } from "@/lib/studies/access" -import { getLanguageModel, resolveModelForUser } from "@/lib/ai/registry" +import { + getLanguageModel, + resolveModelForUser, + userHasUsableKeyForModel, +} from "@/lib/ai/registry" +import { GEN_PARAMS, maxTokensForItems } from "@/lib/ai/params" import { assertWithinLimit } from "@/lib/ai/usage" import { runAi } from "@/lib/ai/run" import { brainstormSchema, buildBrainstormPrompt } from "@/lib/writing/ai" @@ -23,7 +29,9 @@ async function ownThesis(thesisId: string, userId: string) { async function getModel(userId: string) { const defaultModel = await resolveModelForUser(userId) - if (!defaultModel) throw new Error("No AI model configured") + if (!defaultModel) actionError("AI_NO_MODEL") + // BYOK dead-end: model configured but no usable key for its provider (F4). + if (!(await userHasUsableKeyForModel(defaultModel, userId))) actionError("AI_SETUP_REQUIRED") return { ref: defaultModel, model: await getLanguageModel(defaultModel, userId) } } @@ -62,7 +70,7 @@ export async function createThesis(input: unknown) { columns: { id: true }, }) if (existing) { - throw new Error("Für diesen Studiengang existiert bereits eine aktive Abschlussarbeit.") + actionError("THESIS_ACTIVE_EXISTS") } } @@ -89,29 +97,47 @@ export async function retryThesis(thesisId: string) { if (prev.programId) { const program = await ownProgram(prev.programId, session.user.id) if (prev.attempt >= program.thesisMaxAttempts) { - throw new Error("Maximale Versuchszahl für die Abschlussarbeit erreicht.") + actionError("THESIS_MAX_ATTEMPTS") } } - const [created] = await db - .insert(writingProject) - .values({ - userId: session.user.id, - title: prev.title, - thesisType: prev.thesisType, - programId: prev.programId, - goalId: prev.goalId, - semesterId: prev.semesterId, - attempt: prev.attempt + 1, - phase: "topic", - }) - .returning({ id: writingProject.id }) - await db - .update(writingProject) - .set({ supersededById: created.id }) - .where(eq(writingProject.id, thesisId)) + // `thesis_active_per_program_uq` is a partial unique index over + // (userId, programId) where `supersededById is null and programId is not + // null`. Partial indexes aren't deferrable, so it's checked per-statement: + // inserting the new attempt with `programId` set while the old row is still + // live would momentarily leave TWO live rows for the program → violation. + // Order the writes inside a transaction so the predicate never matches two + // rows at once: (1) insert the new attempt WITHOUT programId (predicate + // false), (2) supersede the old row (it drops out of the predicate), + // (3) attach programId to the new row (now the only live row for the program). + const newId = await db.transaction(async (tx) => { + const [created] = await tx + .insert(writingProject) + .values({ + userId: session.user.id, + title: prev.title, + thesisType: prev.thesisType, + programId: null, + goalId: prev.goalId, + semesterId: prev.semesterId, + attempt: prev.attempt + 1, + phase: "topic", + }) + .returning({ id: writingProject.id }) + await tx + .update(writingProject) + .set({ supersededById: created.id }) + .where(eq(writingProject.id, thesisId)) + if (prev.programId) { + await tx + .update(writingProject) + .set({ programId: prev.programId }) + .where(eq(writingProject.id, created.id)) + } + return created.id + }) revalidatePath("/thesis") - return { ok: true as const, id: created.id } + return { ok: true as const, id: newId } } const thesisUpdateSchema = thesisSchema.partial().extend({ @@ -152,6 +178,8 @@ export async function brainstormTopics(interests: string) { model, schema: brainstormSchema, prompt: buildBrainstormPrompt(interests), + ...GEN_PARAMS, + maxOutputTokens: maxTokensForItems(8), }) ) return object.topics diff --git a/src/app/api/ai/chat/route.ts b/src/app/api/ai/chat/route.ts index 5b2e2c1..a38d84c 100644 --- a/src/app/api/ai/chat/route.ts +++ b/src/app/api/ai/chat/route.ts @@ -4,8 +4,12 @@ import { and, asc, eq, gte, inArray } from "drizzle-orm" import { db } from "@/db" import { aiConversation, aiMessage, studyEvent } from "@/db/schema" import { getSession } from "@/lib/auth/session" -import { getLanguageModel, listAvailableModels } from "@/lib/ai/registry" -import { assertWithinLimit } from "@/lib/ai/usage" +import { + getLanguageModel, + listAvailableModels, + userHasUsableKeyForModel, +} from "@/lib/ai/registry" +import { assertWithinLimit, isOverLimit } from "@/lib/ai/usage" import { normalizeUsage, recordAiAudit, recordAiUsage } from "@/lib/ai/run" import { searchChunks, searchChunksInMaterials } from "@/lib/ai/rag" import { MODE_PROMPTS, type ChatMode } from "@/lib/ai/modes" @@ -78,6 +82,16 @@ export async function POST(request: Request) { }) } + // Reject oversized bodies before buffering them: `request.json()` reads the + // whole payload into memory, so the per-message char/history caps below apply + // too late to stop a huge upload. The real message cap is ~32k chars; 1 MB + // leaves generous headroom for JSON/attachment overhead. + const MAX_BODY_BYTES = 1_000_000 + const contentLength = Number(request.headers.get("content-length") ?? "") + if (Number.isFinite(contentLength) && contentLength > MAX_BODY_BYTES) { + return new Response("Payload too large", { status: 413 }) + } + const body = (await request.json().catch(() => null)) as { messages: UIMessage[] conversationId: string @@ -226,6 +240,11 @@ export async function POST(request: Request) { const ai = await getSetting("ai") const ragEnabled = Boolean(ai?.defaultEmbeddingModel) const userId = session.user.id + // Whether this user can authenticate a request for the chosen model. Computed + // up front so the synchronous stream `onError` can tell a BYOK dead-end (no + // usable key → point the user at Settings) apart from a genuine auth failure + // on a configured key (F4). + const canAuthModel = await userHasUsableKeyForModel(body.model, userId) const moduleId = conversation.moduleId const scopedMaterialId = conversation.materialId @@ -419,7 +438,11 @@ export async function POST(request: Request) { onError: (error) => { console.error("[chat] stream error", error) const message = error instanceof Error ? error.message : String(error) - if (/api.?key|unauthorized|authentication|401|403/i.test(message)) return "AI_ERROR:auth" + if (/api.?key|unauthorized|authentication|401|403/i.test(message)) { + // No usable key for this provider → actionable setup hint (F4); an auth + // failure on a configured key stays the generic provider-rejected code. + return canAuthModel ? "AI_ERROR:auth" : "AI_ERROR:no-key" + } if (/rate.?limit|quota|429|overloaded/i.test(message)) return "AI_ERROR:rate-limit" return "AI_ERROR:generic" }, @@ -449,7 +472,9 @@ export async function POST(request: Request) { .filter((p): p is { type: "text"; text: string } => p.type === "text") .map((p) => p.text) .join(" ") - if (userText) { + // Title generation is a paid model call; degrade gracefully over the + // monthly cap by keeping the truncated placeholder title. + if (userText && !(await isOverLimit(userId))) { try { const { generateText } = await import("ai") const { UTILITY_PARAMS } = await import("@/lib/ai/params") diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index 36036b1..7209e41 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -8,7 +8,9 @@ export const dynamic = "force-dynamic" /** * Verifies the storage backend accepts writes. A read-only uploads volume * (classic Docker bind-mount ownership problem) is the most common reason - * "uploads don't work" — surface it here instead of as opaque 500s. + * "uploads don't work". This performs a real write, so it is NOT run on the + * default liveness path — it is gated behind `?full=1` + HEALTH_TOKEN and + * cached for ~30s to avoid a storage write on every hit. */ async function checkStorage(): Promise { const probe = `health-${crypto.randomUUID()}.txt` @@ -22,17 +24,39 @@ async function checkStorage(): Promise { } } -export async function GET() { +let storageCache: { at: number; ok: boolean } | null = null + +async function cachedStorageCheck(): Promise { + const now = Date.now() + if (storageCache && now - storageCache.at < 30_000) return storageCache.ok + const ok = await checkStorage() + storageCache = { at: now, ok } + return ok +} + +export async function GET(request: Request) { let database = "up" try { await db.execute(sql`select 1`) } catch { database = "down" } - const storage = (await checkStorage()) ? "up" : "down" - const ok = database === "up" && storage === "up" + + // The full check writes to storage; require an explicit opt-in AND a token + // (only honored when HEALTH_TOKEN is configured). Default GET is read-only. + const url = new URL(request.url) + const token = process.env.HEALTH_TOKEN + const wantsFull = + url.searchParams.get("full") === "1" && !!token && url.searchParams.get("token") === token + + let storage: "up" | "down" | undefined + if (wantsFull) { + storage = (await cachedStorageCheck()) ? "up" : "down" + } + + const ok = database === "up" && storage !== "down" return NextResponse.json( - { status: ok ? "ok" : "degraded", database, storage }, + { status: ok ? "ok" : "degraded", database, ...(storage ? { storage } : {}) }, { status: ok ? 200 : 503 } ) } diff --git a/src/app/api/materials/[id]/file/route.ts b/src/app/api/materials/[id]/file/route.ts index 61dc025..d73bac8 100644 --- a/src/app/api/materials/[id]/file/route.ts +++ b/src/app/api/materials/[id]/file/route.ts @@ -48,6 +48,16 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: }, }) } + // A range was requested but is unsatisfiable (start past EOF, or start > + // end). Per RFC 7233 answer 416 with `Content-Range: bytes */` + // rather than silently returning the full 200 body. + return new Response(null, { + status: 416, + headers: { + "Content-Range": `bytes */${size}`, + "Accept-Ranges": "bytes", + }, + }) } } diff --git a/src/app/api/materials/tus/[[...slug]]/route.ts b/src/app/api/materials/tus/[[...slug]]/route.ts index 2ec993c..4f1deff 100644 --- a/src/app/api/materials/tus/[[...slug]]/route.ts +++ b/src/app/api/materials/tus/[[...slug]]/route.ts @@ -36,9 +36,25 @@ function getServer(): Server { // hit the web tier, and finalization happens later in a background job. locker: new MemoryLocker(), respectForwardedHeaders: true, - async onIncomingRequest() { + async onIncomingRequest(req, uploadId) { const session = await getSession() if (!session) throw httpError(401, "Unauthorized") + + // For requests against an EXISTING upload (resume/status/abort), verify + // the stamped owner matches the caller. onUploadCreate stamps userId; POST + // creation carries a not-yet-persisted id, so only guard PATCH/HEAD/DELETE. + const method = req.method?.toUpperCase() + if ((method === "PATCH" || method === "HEAD" || method === "DELETE") && uploadId) { + let upload + try { + upload = await server.datastore.getUpload(uploadId) + } catch { + return // unknown id — let tus return its normal 404 + } + if (metaString(upload.metadata?.userId) !== session.user.id) { + throw httpError(404, "Not found") + } + } }, async onUploadCreate(_req, upload) { const session = await getSession() diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index d131e21..c4e94c7 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -1,18 +1,21 @@ import { NextResponse } from "next/server" -import { and, eq, ilike, or, sql } from "drizzle-orm" +import { and, eq, exists, ilike, or, sql } from "drizzle-orm" import { db } from "@/db" import { assignment, deck, degreeProgram, + flashcard, material, moduleContact, + question, quiz, semester, studyEvent, studyModule, } from "@/db/schema" import { getSession } from "@/lib/auth/session" +import { likePattern } from "@/lib/search/query" import { checkRateLimit, tooManyRequests } from "@/lib/rate-limit" export async function GET(request: Request) { @@ -37,7 +40,11 @@ export async function GET(request: Request) { }) } // Escape LIKE wildcards so user input matches literally - const pattern = `%${q.replace(/[\\%_]/g, "\\$&")}%` + const pattern = likePattern(q) + // Full-text match over the (indexed) material text. websearch_to_tsquery + // parses the raw user query safely (no manual escaping needed) and matches + // the 'german' config of the generated text_content_tsv column + GIN index. + const materialTextMatch = sql`${material.textContentTsv} @@ websearch_to_tsquery('german', ${q})` // Module-scoped entities join up to the program for link building. const moduleScope = { @@ -69,10 +76,7 @@ export async function GET(request: Request) { .where( and( eq(material.userId, session.user.id), - or( - ilike(material.name, pattern), - sql`${material.textContent} ILIKE ${pattern}` - ) + or(ilike(material.name, pattern), materialTextMatch) ) ) .limit(5), @@ -86,14 +90,47 @@ export async function GET(request: Request) { .from(deck) .innerJoin(studyModule, eq(deck.moduleId, studyModule.id)) .innerJoin(semester, eq(studyModule.semesterId, semester.id)) - .where(and(eq(deck.userId, session.user.id), ilike(deck.name, pattern))) + .where( + and( + eq(deck.userId, session.user.id), + or( + ilike(deck.name, pattern), + // Widen to card content (front/back) — bounded to this deck. + exists( + db + .select({ one: sql`1` }) + .from(flashcard) + .where( + and( + eq(flashcard.deckId, deck.id), + or(ilike(flashcard.front, pattern), ilike(flashcard.back, pattern)) + ) + ) + ) + ) + ) + ) .limit(5), db .select({ id: quiz.id, title: quiz.title, ...moduleScope }) .from(quiz) .innerJoin(studyModule, eq(quiz.moduleId, studyModule.id)) .innerJoin(semester, eq(studyModule.semesterId, semester.id)) - .where(and(eq(quiz.userId, session.user.id), ilike(quiz.title, pattern))) + .where( + and( + eq(quiz.userId, session.user.id), + or( + ilike(quiz.title, pattern), + // Widen to question prompts — bounded to this quiz. + exists( + db + .select({ one: sql`1` }) + .from(question) + .where(and(eq(question.quizId, quiz.id), ilike(question.prompt, pattern))) + ) + ) + ) + ) .limit(5), db .select({ id: assignment.id, title: assignment.title, ...moduleScope }) diff --git a/src/components/action-error-toast.ts b/src/components/action-error-toast.ts new file mode 100644 index 0000000..2fe0bd9 --- /dev/null +++ b/src/components/action-error-toast.ts @@ -0,0 +1,29 @@ +"use client" + +import * as React from "react" +import { useTranslations } from "next-intl" +import { toast } from "sonner" +import { extractActionErrorCode } from "@/lib/action-errors" + +/** + * Shows a localized error toast for a thrown server-action error. `t` is the + * `errors` translation namespace (`useTranslations("errors")`). A recognized + * `ERR:` token maps to `errors.`; anything else — unknown codes, + * raw provider strings, network errors — falls back to `errors.GENERIC` so a + * server's (possibly wrong-language) message never reaches the UI. Mirrors the + * `AI_ERROR:` handling in `chat.tsx`. + */ +export function toastActionError(error: unknown, t: (code: string) => string): void { + const code = extractActionErrorCode(error) + toast.error(t(code ?? "GENERIC")) +} + +/** + * Convenience hook: returns a stable callback that shows a localized toast for + * a thrown server-action error. Prefer this in client components so call sites + * don't have to wire the `errors` namespace themselves. + */ +export function useActionErrorToast(): (error: unknown) => void { + const t = useTranslations("errors") + return React.useCallback((error: unknown) => toastActionError(error, t), [t]) +} diff --git a/src/components/ai/chat-dock.tsx b/src/components/ai/chat-dock.tsx index 1a8d978..c0d119a 100644 --- a/src/components/ai/chat-dock.tsx +++ b/src/components/ai/chat-dock.tsx @@ -1,18 +1,23 @@ "use client" import * as React from "react" +import dynamic from "next/dynamic" import { MessageCircle, X } from "lucide-react" import { useTranslations } from "next-intl" import { usePathname } from "@/i18n/navigation" -import { - CHAT_OPEN_EVENT, - ConversationPanel, - LAST_CHAT_KEY, -} from "@/components/ai/conversation-panel" +import { CHAT_OPEN_EVENT, LAST_CHAT_KEY } from "@/components/ai/chat-events" import type { ModuleOption } from "@/components/learn/module-select" import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" +// The conversation panel drags in the AI SDK, react-markdown and katex. Load it +// on demand (client-only) so none of that lands in the shared layout bundle — +// it is fetched the first time the dock actually opens (F1). +const ConversationPanel = dynamic( + () => import("@/components/ai/conversation-panel").then((m) => m.ConversationPanel), + { ssr: false } +) + export { CHAT_OPEN_EVENT } /** Floating chat launcher + dock panel (conversation UI lives in ConversationPanel). */ diff --git a/src/components/ai/chat-events.ts b/src/components/ai/chat-events.ts new file mode 100644 index 0000000..eb15e98 --- /dev/null +++ b/src/components/ai/chat-events.ts @@ -0,0 +1,8 @@ +/** + * Lightweight chat dock constants. Kept in their own module (no React / AI-SDK + * imports) so the always-mounted `ChatDock` and `BottomNav` can reference them + * without pulling the heavy `ConversationPanel` (AI SDK + markdown + katex) into + * the shared layout bundle — that panel is loaded via `next/dynamic` on open. + */ +export const LAST_CHAT_KEY = "studyhelper.lastChatId" +export const CHAT_OPEN_EVENT = "studyhelper:chat-open" diff --git a/src/components/ai/chat.tsx b/src/components/ai/chat.tsx index 9a706dd..f3dece6 100644 --- a/src/components/ai/chat.tsx +++ b/src/components/ai/chat.tsx @@ -29,20 +29,57 @@ import { VoiceInputButton } from "./voice-input-button" import { describePageContext, usePageContext } from "./page-context" import { cn } from "@/lib/utils" -function summarizeInput(input: unknown): { key: string; value: string }[] { +/** + * The fields shown on each write-tool confirmation card, in display order. + * A whitelist keeps opaque ids (goalId, correctIndex, the moduleId uuid) and + * bulky payloads (raw card/question arrays) out of the card — only the fields a + * user needs to confirm what will be created are rendered (F7). + */ +const CONFIRM_FIELDS: Record = { + createDeckWithCards: ["name", "moduleId", "cards"], + createQuizWithQuestions: ["title", "moduleId", "questions"], + createCalendarEvent: ["title", "type", "startsAt", "endsAt", "location", "moduleId"], + createAssignment: ["title", "moduleId", "dueDate", "pointsMax"], +} + +const EVENT_TYPES = ["exam", "deadline", "lecture", "other"] as const + +/** + * Builds the translated, id-free rows for a write-tool confirmation card. + * `moduleId` is resolved to the module NAME via `resolveModuleName` and hidden + * when it can't be resolved; arrays collapse to a count; the event `type` and + * field labels are localized (F7). + */ +function buildConfirmRows( + toolName: string, + input: unknown, + resolveModuleName: (id: string) => string | undefined, + t: (key: string) => string +): { label: string; value: string }[] { if (!input || typeof input !== "object") return [] - return Object.entries(input as Record) - .filter(([, v]) => v != null && v !== "") - .map(([key, value]) => ({ - key, - value: Array.isArray(value) - ? `${value.length}` - : typeof value === "string" - ? value.length > 120 - ? value.slice(0, 120) + "…" - : value - : String(value), - })) + const values = input as Record + const rows: { label: string; value: string }[] = [] + for (const key of CONFIRM_FIELDS[toolName] ?? []) { + const v = values[key] + if (v == null || v === "") continue + if (key === "moduleId") { + const name = resolveModuleName(String(v)) + if (!name) continue + rows.push({ label: t("tool.fields.module"), value: name }) + } else if (Array.isArray(v)) { + rows.push({ label: t(`tool.fields.${key}`), value: String(v.length) }) + } else if (key === "type") { + const type = String(v) + const value = (EVENT_TYPES as readonly string[]).includes(type) + ? t(`tool.eventTypes.${type}`) + : type + rows.push({ label: t("tool.fields.type"), value }) + } else { + const str = String(v) + rows.push({ label: t(`tool.fields.${key}`), value: str.length > 120 ? str.slice(0, 120) + "…" : str }) + } + } + return rows } type ToolOutput = @@ -110,11 +147,13 @@ function ToolCard({ toolName, part, conversationId, + resolveModuleName, onResolve, }: { toolName: string part: { state: string; input?: unknown; output?: unknown } conversationId: string + resolveModuleName: (id: string) => string | undefined onResolve: (output: ToolOutput) => void }) { const t = useTranslations("ai") @@ -144,9 +183,9 @@ function ToolCard({

        {part.state !== "output-available" && (
        - {summarizeInput(part.input).map(({ key, value }) => ( -
        -
        {key}:
        + {buildConfirmRows(toolName, part.input, resolveModuleName, t).map(({ label, value }) => ( +
        +
        {label}:
        {value}
        ))} @@ -224,6 +263,15 @@ export function Chat({ const bottomRef = React.useRef(null) const pageContext = usePageContext() + // Resolve a write-tool's moduleId to a human name for the confirmation card, + // so it never shows a raw uuid (F7). The current page's module is the only + // reliable client-side lookup; an unknown id resolves to undefined and the + // row is hidden rather than exposing the id. + const resolveModuleName = React.useCallback( + (id: string): string | undefined => + pageContext?.moduleId && id === pageContext.moduleId ? pageContext.moduleName : undefined, + [pageContext] + ) // Auto-continued requests (after tool confirmation) go through the transport // body; per-send bodies are merged on top for regular sends. A stable holder @@ -365,6 +413,7 @@ export function Chat({ toolName={part.type.slice(5)} part={toolPart} conversationId={conversationId} + resolveModuleName={resolveModuleName} onResolve={(output) => void addToolResult({ tool: part.type.slice(5), @@ -403,12 +452,21 @@ export function Chat({

        {/* Server categorizes stream errors as AI_ERROR:; anything else stays generic so provider internals never reach the UI. */} - {error.message === "AI_ERROR:auth" - ? t("errorAuth") - : error.message === "AI_ERROR:rate-limit" || - error.message.toLowerCase().includes("limit") - ? t("errorLimit") - : t("errorGeneric")} + {error.message === "AI_ERROR:no-key" ? ( + <> + {t("errorNoKey")}{" "} + + {t("errorNoKeyCta")} + + + ) : error.message === "AI_ERROR:auth" ? ( + t("errorAuth") + ) : error.message === "AI_ERROR:rate-limit" || + error.message.toLowerCase().includes("limit") ? ( + t("errorLimit") + ) : ( + t("errorGeneric") + )}

        )}
        diff --git a/src/components/ai/conversation-panel.tsx b/src/components/ai/conversation-panel.tsx index dbef83b..b6618e8 100644 --- a/src/components/ai/conversation-panel.tsx +++ b/src/components/ai/conversation-panel.tsx @@ -38,9 +38,7 @@ import { } from "@/components/ui/dropdown-menu" import { Input } from "@/components/ui/input" import { ConfirmDeleteDialog } from "@/components/studies/confirm-delete-dialog" - -export const LAST_CHAT_KEY = "studyhelper.lastChatId" -export const CHAT_OPEN_EVENT = "studyhelper:chat-open" +import { CHAT_OPEN_EVENT, LAST_CHAT_KEY } from "@/components/ai/chat-events" export type ConversationMeta = { id: string diff --git a/src/components/calendar/calendar-view-dynamic.tsx b/src/components/calendar/calendar-view-dynamic.tsx new file mode 100644 index 0000000..2c7bfea --- /dev/null +++ b/src/components/calendar/calendar-view-dynamic.tsx @@ -0,0 +1,35 @@ +"use client" + +import * as React from "react" +import dynamic from "next/dynamic" +import type { CalendarView as CalendarViewImpl } from "@/components/calendar/calendar-view" +import { Card, CardContent } from "@/components/ui/card" +import { Skeleton } from "@/components/ui/skeleton" + +function CalendarSkeleton() { + return ( + + +
        + {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
        + +
        +
        + ) +} + +// FullCalendar (@fullcalendar/react + daygrid/timegrid/interaction) is a heavy +// grid library. Load it client-only and off the critical path so the page shell +// paints immediately behind a skeleton (F2). SSR is disabled because the grid +// relies on layout measurement in the browser. +const CalendarViewLazy = dynamic( + () => import("@/components/calendar/calendar-view").then((m) => m.CalendarView), + { ssr: false, loading: () => } +) + +export function CalendarView(props: React.ComponentProps) { + return +} diff --git a/src/components/calendar/calendar-view.tsx b/src/components/calendar/calendar-view.tsx index b7b52bf..74ebf9a 100644 --- a/src/components/calendar/calendar-view.tsx +++ b/src/components/calendar/calendar-view.tsx @@ -4,15 +4,16 @@ import * as React from "react" import FullCalendar from "@fullcalendar/react" import dayGridPlugin from "@fullcalendar/daygrid" import timeGridPlugin from "@fullcalendar/timegrid" +import listPlugin from "@fullcalendar/list" import interactionPlugin from "@fullcalendar/interaction" import deLocale from "@fullcalendar/core/locales/de" import type { EventClickArg, EventDropArg, EventInput } from "@fullcalendar/core" import type { EventResizeDoneArg } from "@fullcalendar/interaction" -import { Pencil, Trash2 } from "lucide-react" +import { Pencil, Sparkles, Trash2 } from "lucide-react" import { useLocale, useTranslations } from "next-intl" import { toast } from "sonner" import { useRouter } from "@/i18n/navigation" -import { deleteEvent, moveEvent } from "@/app/[locale]/(app)/calendar/actions" +import { deleteEvent, deleteEventOccurrence, moveEvent } from "@/app/[locale]/(app)/calendar/actions" import { moveSession } from "@/app/[locale]/(app)/plan/schedule-actions" import { EventDialog, type EventData, type ModuleOption } from "./event-dialog" import { SessionDialog, type SessionDialogData } from "./session-dialog" @@ -29,7 +30,7 @@ import { } from "@/components/ui/select" import { cn } from "@/lib/utils" -export type CalendarEvent = EventData & { id: string } +export type CalendarEvent = EventData & { id: string; aiGenerated?: boolean } export type PlanCalendarSession = { id: string @@ -91,6 +92,7 @@ export function CalendarView({ planSessions = [], assignments = [], absences = [], + focusEventId, }: { events: CalendarEvent[] modules: ModuleOption[] @@ -98,6 +100,8 @@ export function CalendarView({ assignments?: AssignmentCalendarItem[] /** Unavailability windows from the study plans, shown as background. */ absences?: AbsenceWindow[] + /** Event id to focus/open on mount (command-palette deep link, E24). */ + focusEventId?: string }) { const locale = useLocale() const t = useTranslations("calendar.filters") @@ -110,7 +114,22 @@ export function CalendarView({ const [sessionOpen, setSessionOpen] = React.useState(false) const [moduleFilter, setModuleFilter] = React.useState("") const [hidden, setHidden] = React.useState>(new Set()) - const [ctxMenu, setCtxMenu] = React.useState<{ id: string; x: number; y: number } | null>(null) + const [ctxMenu, setCtxMenu] = React.useState<{ + id: string + x: number + y: number + /** Set when the right-clicked item is a single recurring occurrence (E18). */ + occurrenceDate?: string + } | null>(null) + const calendarRef = React.useRef(null) + + // Default phones to the agenda (listWeek); larger screens keep the month grid + // (E21). Only the initial view is derived — the toolbar lets users switch. + const [initialView] = React.useState(() => + typeof window !== "undefined" && window.matchMedia("(max-width: 640px)").matches + ? "listWeek" + : "dayGridMonth" + ) // Close the right-click menu on outside interaction or Escape. Other keys // (Tab/Enter/arrows) must keep working so the menu is keyboard-operable. @@ -136,6 +155,22 @@ export function CalendarView({ [planSessions] ) + // Deep link (?event=): open the event's dialog and jump the calendar to + // its date once on mount (E24). + const focusedRef = React.useRef(false) + React.useEffect(() => { + if (focusedRef.current || !focusEventId) return + const data = byId.get(focusEventId) + if (!data) return + focusedRef.current = true + // Defer so this doesn't run as a synchronous setState cascade in the effect. + queueMicrotask(() => { + setSelected(data) + setDialogOpen(true) + calendarRef.current?.getApi().gotoDate(new Date(data.startsAt)) + }) + }, [focusEventId, byId]) + const categoryLabels: Record = { exam: tEvent("typeExam"), deadline: tEvent("typeDeadline"), @@ -182,6 +217,15 @@ export function CalendarView({ } } + async function deleteOccurrence(id: string, occurrenceDate: string) { + try { + await deleteEventOccurrence(id, occurrenceDate) + router.refresh() + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)) + } + } + function openNew(dateStr: string) { const startsAt = dateStr.includes("T") ? toLocalInputValue(new Date(dateStr)) @@ -258,6 +302,7 @@ export function CalendarView({ end: e.endsAt ? (e.allDay ? e.endsAt.slice(0, 10) : e.endsAt) : undefined, allDay: e.allDay ?? false, classNames: [TYPE_CLASS[e.type]], + extendedProps: { aiGenerated: e.aiGenerated ?? false }, }, ] } @@ -274,6 +319,7 @@ export function CalendarView({ recurrenceUntil: e.recurrenceUntil ?? null, recurrenceWeekdays: e.recurrenceWeekdays ?? null, recurrenceInterval: e.recurrenceInterval ?? null, + skipDates: e.skipDates ?? null, }, from, to @@ -289,6 +335,7 @@ export function CalendarView({ allDay: e.allDay ?? false, editable: !occ.isRecurrenceInstance, classNames: [TYPE_CLASS[e.type]], + extendedProps: { aiGenerated: e.aiGenerated ?? false }, })) }), ...(!hidden.has("plan") @@ -377,12 +424,14 @@ export function CalendarView({
        { + // Mark AI-generated events with a small sparkle, consistent with + // decks/assignments and the mini-calendar badge (F8). Returning + // `true` for everything else keeps FullCalendar's default rendering. + if (!(arg.event.extendedProps as { aiGenerated?: boolean }).aiGenerated) return true + return ( +
        + {arg.timeText &&
        {arg.timeText}
        } +
        +
        + + {arg.event.title} +
        +
        +
        + ) + }} eventClick={(arg) => { if (arg.event.id.startsWith("assignment:")) { const href = (arg.event.extendedProps as { href?: string }).href @@ -414,13 +480,17 @@ export function CalendarView({ eventDrop={onMove} eventResize={onMove} eventDidMount={(info) => { - // Right-click a real event → edit/delete menu (instances target the series). + // Right-click a real event → edit/delete menu (instances target the + // series, but also offer "delete only this occurrence", E18). const isInstance = info.event.id.startsWith("recur:") if (info.event.id.includes(":") && !isInstance) return - const baseId = isInstance ? info.event.id.split(":")[1] : info.event.id + // recur id shape: `recur:${baseId}:${occurrenceDate}`. + const parts = info.event.id.split(":") + const baseId = isInstance ? parts[1] : info.event.id + const occurrenceDate = isInstance ? parts[2] : undefined info.el.addEventListener("contextmenu", (e) => { e.preventDefault() - setCtxMenu({ id: baseId, x: e.clientX, y: e.clientY }) + setCtxMenu({ id: baseId, x: e.clientX, y: e.clientY, occurrenceDate }) }) }} /> @@ -448,6 +518,20 @@ export function CalendarView({ {tCommon("edit")} + {ctxMenu.occurrenceDate && ( + + )}
        )} diff --git a/src/components/calendar/event-dialog.tsx b/src/components/calendar/event-dialog.tsx index 5abf726..0dfec3f 100644 --- a/src/components/calendar/event-dialog.tsx +++ b/src/components/calendar/event-dialog.tsx @@ -51,6 +51,8 @@ export type EventData = { recurrenceUntil?: string | null recurrenceWeekdays?: number[] | null recurrenceInterval?: number | null + /** Local ISO dates of individually-deleted occurrences (E18). */ + skipDates?: string[] | null } export type ModuleOption = { id: string; name: string } diff --git a/src/components/calendar/session-dialog.tsx b/src/components/calendar/session-dialog.tsx index e96cb41..d9c1abe 100644 --- a/src/components/calendar/session-dialog.tsx +++ b/src/components/calendar/session-dialog.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { useFormatter, useTranslations } from "next-intl" -import { toast } from "sonner" +import { useActionErrorToast } from "@/components/action-error-toast" import { useRouter } from "@/i18n/navigation" import { togglePlanTask } from "@/app/[locale]/(app)/plan/plan-task-actions" import { toggleSession } from "@/app/[locale]/(app)/plan/schedule-actions" @@ -33,16 +33,22 @@ export function SessionDialog({ const t = useTranslations("plan.session") const format = useFormatter() const router = useRouter() + const showError = useActionErrorToast() + const [pending, startTransition] = React.useTransition() if (!session) return null - async function run(fn: () => Promise) { - try { - await fn() - router.refresh() - } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)) - } + function run(fn: () => Promise) { + // Disable the controls while a toggle is in flight so rapid taps can't + // double-fire against the server (D13). + startTransition(async () => { + try { + await fn() + router.refresh() + } catch (error) { + showError(error) + } + }) } return ( @@ -64,7 +70,8 @@ export function SessionDialog({ @@ -82,9 +89,8 @@ export function SessionDialog({ > - void run(() => togglePlanTask(task.id, Boolean(on))) - } + disabled={pending} + onCheckedChange={(on) => run(() => togglePlanTask(task.id, Boolean(on)))} /> {task.title} diff --git a/src/components/form-dialog.tsx b/src/components/form-dialog.tsx index 4793d1a..d75c9f9 100644 --- a/src/components/form-dialog.tsx +++ b/src/components/form-dialog.tsx @@ -3,7 +3,7 @@ import * as React from "react" import { Loader2 } from "lucide-react" import { useTranslations } from "next-intl" -import { toast } from "sonner" +import { useActionErrorToast } from "@/components/action-error-toast" import { Button } from "@/components/ui/button" import { Dialog, @@ -12,6 +12,7 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog" +import { cn } from "@/lib/utils" /** * Shared scaffolding for the app's create/edit dialogs: dialog shell, form, @@ -30,8 +31,11 @@ export function FormDialog({ pendingLabel, trigger, triggerVariant = "default", + triggerSize, open: controlledOpen, onOpenChange, + scrollable = false, + contentClassName, }: { title: string children: React.ReactNode @@ -44,10 +48,16 @@ export function FormDialog({ /** Trigger button contents (icon + label) for the self-managed mode. */ trigger?: React.ReactNode triggerVariant?: React.ComponentProps["variant"] + triggerSize?: React.ComponentProps["size"] open?: boolean onOpenChange?: (open: boolean) => void + /** Cap the dialog height and scroll its body — for tall forms (F16). */ + scrollable?: boolean + /** Extra classes on the dialog content (e.g. a wider `sm:max-w-lg`). */ + contentClassName?: string }) { const tCommon = useTranslations("common") + const showError = useActionErrorToast() const [internalOpen, setInternalOpen] = React.useState(false) const [pending, setPending] = React.useState(false) @@ -62,7 +72,7 @@ export function FormDialog({ await onSubmit(form) setOpen(false) } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)) + showError(error) } finally { setPending(false) } @@ -71,9 +81,13 @@ export function FormDialog({ return ( {trigger != null && ( - }>{trigger} + }> + {trigger} + )} - + {title} diff --git a/src/components/layout/app-header.tsx b/src/components/layout/app-header.tsx index 92769e3..8ba1cfe 100644 --- a/src/components/layout/app-header.tsx +++ b/src/components/layout/app-header.tsx @@ -5,14 +5,16 @@ import { UserMenu } from "./user-menu" export function AppHeader({ user, + aiAvailable, }: { user: { name: string; email: string; image?: string | null; isAdmin: boolean } + aiAvailable: boolean }) { return (
        - +
        {/* On desktop these controls live in the sidebar footer; keep them here for mobile where the sidebar is hidden. */} diff --git a/src/components/layout/command-palette.tsx b/src/components/layout/command-palette.tsx index 1464ed6..db15c81 100644 --- a/src/components/layout/command-palette.tsx +++ b/src/components/layout/command-palette.tsx @@ -38,7 +38,7 @@ const EMPTY: SearchResults = { contacts: [], } -export function CommandPalette() { +export function CommandPalette({ aiAvailable }: { aiAvailable: boolean }) { const [open, setOpen] = React.useState(false) const [query, setQuery] = React.useState("") const [results, setResults] = React.useState(EMPTY) @@ -139,7 +139,7 @@ export function CommandPalette() { {results.events.length > 0 && ( {results.events.map((e) => ( - go("/calendar")}> + go(`/calendar?event=${e.id}`)}> {e.title} @@ -203,7 +203,9 @@ export function CommandPalette() { )} - {navItems.map((item) => ( + {navItems + .filter((item) => aiAvailable || item.key !== "ai") + .map((item) => ( go(item.href)}> {tNav(item.key)} diff --git a/src/components/layout/locale-switcher.tsx b/src/components/layout/locale-switcher.tsx index cc74a51..4ef3a8d 100644 --- a/src/components/layout/locale-switcher.tsx +++ b/src/components/layout/locale-switcher.tsx @@ -5,6 +5,7 @@ import { useLocale, useTranslations } from "next-intl" import { useParams } from "next/navigation" import { routing } from "@/i18n/routing" import { usePathname, useRouter } from "@/i18n/navigation" +import { updateLocalePref } from "@/app/[locale]/(app)/settings/actions" import { Button } from "@/components/ui/button" import { DropdownMenu, @@ -21,6 +22,9 @@ export function LocaleSwitcher() { const params = useParams() function switchTo(nextLocale: string) { + // Remember the choice for background reminders (push/email). Fire-and-forget + // — a failure here must not block the UI language switch. + void updateLocalePref(nextLocale).catch(() => {}) router.replace( // @ts-expect-error — params are compatible with the current pathname { pathname, params }, diff --git a/src/components/learn/deck-card.tsx b/src/components/learn/deck-card.tsx index 225a067..fedcd95 100644 --- a/src/components/learn/deck-card.tsx +++ b/src/components/learn/deck-card.tsx @@ -1,7 +1,7 @@ "use client" import * as React from "react" -import { Layers, MoreHorizontal, Pencil, Play, Trash2 } from "lucide-react" +import { Layers, MoreHorizontal, Pencil, Play, Trash2, Wand2 } from "lucide-react" import { useTranslations } from "next-intl" import { Link, useRouter } from "@/i18n/navigation" import { deleteDeck } from "@/app/[locale]/(app)/deck-actions" @@ -11,6 +11,7 @@ import { ConfirmDeleteDialog } from "@/components/studies/confirm-delete-dialog" import { EntityContextMenu, type ContextMenuAction } from "@/components/entity-context-menu" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { DropdownMenu, DropdownMenuContent, @@ -24,6 +25,8 @@ export type DeckCardData = { name: string description: string | null aiGenerated: boolean + /** `"mistakes"` decks are auto-created from wrong quiz answers (system-owned). */ + kind: "normal" | "mistakes" cardCount: number dueCount: number } @@ -44,6 +47,10 @@ export function DeckCard({ const t = useTranslations("learn.decks") const tCommon = useTranslations("common") const router = useRouter() + // A mistakes deck is system-owned: render a stable localized label + badge + // regardless of the (locale-at-creation) name stored in the DB. + const isMistakes = deck.kind === "mistakes" + const displayName = isMistakes ? t("mistakesName") : deck.name const [editOpen, setEditOpen] = React.useState(false) const [deleteOpen, setDeleteOpen] = React.useState(false) @@ -81,8 +88,21 @@ export function DeckCard({ href={`${basePath}/decks/${deck.id}`} className="min-w-0 flex-1 font-medium underline-offset-4 hover:underline" > - {deck.name} + {displayName} + {isMistakes && ( + + + + {t("mistakesBadge")} + + } + /> + {t("mistakesTooltip")} + + )} {deck.aiGenerated && } (null) + // >0 while a large single-shot generation is running → estimated progress bar. + const [estCount, setEstCount] = React.useState(0) if (!aiAvailable) return null @@ -235,6 +242,7 @@ export function GenerateCardsDialog({ setJobId(null) setPending(false) setComplete(false) + setEstCount(0) } async function onSubmit(e: React.FormEvent) { @@ -249,9 +257,11 @@ export function GenerateCardsDialog({ }) setJobId(res.jobId) } else { + const count = Number(form.get("count")) + if (count >= LARGE_GENERATION) setEstCount(count) await generateCards({ deckId, - count: Number(form.get("count")), + count, topics: String(form.get("topics") || "") || undefined, }) setOpen(false) @@ -261,6 +271,7 @@ export function GenerateCardsDialog({ toast.error(error instanceof Error ? error.message : String(error)) } finally { setPending(false) + setEstCount(0) } } @@ -295,6 +306,8 @@ export function GenerateCardsDialog({
        + ) : estCount > 0 ? ( + ) : (
      )} - - - - {editing ? t("editAttempt") : t("addAttempt")} - - -
      -
      - - -
      -
      - - -
      -
      - -
      - - -
      -
      - - -
      - -
      -
      + { + const payload = { + resultPercent: + form.get("resultPercent") && String(form.get("resultPercent")).trim() !== "" + ? Number(form.get("resultPercent")) + : null, + date: String(form.get("date") || "") || null, + passed, + note: String(form.get("note") || "") || null, + } + if (editing) await updateAttempt(editing.id, payload) + else await addAttempt(goalId, payload) + router.refresh() + }} + > +
      +
      + + +
      +
      + + +
      +
      + +
      + + +
      +
      ) } @@ -486,6 +535,7 @@ export function GoalCard({ gradingSystem, stats, bonus, + readiness, }: { moduleId: string basePath: string @@ -493,8 +543,10 @@ export function GoalCard({ gradingSystem: GradingSystem stats: { dueCards: number; lastQuizScore: number | null } bonus: BonusProgressDTO | null + readiness?: GoalReadinessDTO }) { const t = useTranslations("goalCard") + const tPlan = useTranslations("plan.readiness") const tBonus = useTranslations("studies.bonus") const format = useFormatter() const { goal } = data @@ -510,6 +562,7 @@ export function GoalCard({ right={ <> + {readiness && } {isGrade && (

      {t("readiness")}

      - +
      + {readiness ? ( + + ) : ( + + )} {showAttempts && ( | null>(null) @@ -73,7 +74,7 @@ export function GradeGoal({ ? t("invalid") : result?.kind === "needed" ? t("needed", { - grade: result.grade.toLocaleString("de-DE", { + grade: result.grade.toLocaleString(locale, { minimumFractionDigits: 1, maximumFractionDigits: 1, }), diff --git a/src/components/learn/mini-calendar.tsx b/src/components/learn/mini-calendar.tsx index 4252b47..9a3f3d5 100644 --- a/src/components/learn/mini-calendar.tsx +++ b/src/components/learn/mini-calendar.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { ArrowRight, ChevronLeft, ChevronRight } from "lucide-react" -import { useLocale, useTranslations } from "next-intl" +import { useFormatter, useTranslations } from "next-intl" import { getMonthEvents, type MiniCalendarEvent } from "@/app/[locale]/(app)/dashboard-actions" import { AiBadge } from "@/components/ai/ai-badge" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" @@ -39,7 +39,7 @@ export function MiniCalendar({ }) { const t = useTranslations("dashboard.miniCalendar") const tCal = useTranslations("calendar") - const locale = useLocale() + const format = useFormatter() const [events, setEvents] = React.useState(initialEvents) const [view, setView] = React.useState({ year, month }) @@ -105,11 +105,11 @@ export function MiniCalendar({ while (cells.length % 7 !== 0) cells.push(null) const todayKey = dayKey(new Date()) - const monthLabel = new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(first) + const monthLabel = format.dateTime(first, { month: "long", year: "numeric" }) const weekdays = Array.from({ length: 7 }, (_, i) => { // 2024-01-01 is a Monday — build short weekday names starting Monday. const d = new Date(2024, 0, 1 + i) - return new Intl.DateTimeFormat(locale, { weekday: "short" }).format(d) + return format.dateTime(d, { weekday: "short" }) }) const selectedEvents = (byDay.get(selected) ?? []).sort((a, b) => { @@ -118,11 +118,11 @@ export function MiniCalendar({ return at - bt }) - const timeFmt = new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit" }) + const timeOpts = { hour: "2-digit", minute: "2-digit" } as const function eventTime(e: MiniCalendarEvent): string { if (e.allDay) return t("allDay") - const start = timeFmt.format(new Date(e.startsAt)) - if (e.endsAt) return `${start}–${timeFmt.format(new Date(e.endsAt))}` + const start = format.dateTime(new Date(e.startsAt), timeOpts) + if (e.endsAt) return `${start}–${format.dateTime(new Date(e.endsAt), timeOpts)}` return start } diff --git a/src/components/learn/onboarding-wizard.tsx b/src/components/learn/onboarding-wizard.tsx index 7184dfc..714d13a 100644 --- a/src/components/learn/onboarding-wizard.tsx +++ b/src/components/learn/onboarding-wizard.tsx @@ -44,6 +44,7 @@ export function OnboardingWizard({ { key: "module" as const, done: hasModule, enabled: hasSemester }, ] const current = steps.find((s) => !s.done && s.enabled) ?? steps[steps.length - 1] + const allDone = steps.every((s) => s.done) return (
      @@ -98,6 +99,23 @@ export function OnboardingWizard({ + {allDone && semesterId && ( + + + + + +
      +

      {t("planTitle")}

      +

      {t("planDesc")}

      +
      + +
      +
      + )} + {isAdmin && !aiConfigured && ( diff --git a/src/components/learn/quiz-dialogs.tsx b/src/components/learn/quiz-dialogs.tsx index 2a9d0d2..d003658 100644 --- a/src/components/learn/quiz-dialogs.tsx +++ b/src/components/learn/quiz-dialogs.tsx @@ -28,6 +28,11 @@ import { startCompleteQuiz } from "@/app/[locale]/(app)/generation-actions" import { FormDialog } from "@/components/form-dialog" import { ModuleSelect, type ModuleOption } from "./module-select" import { GenerationProgress } from "./generation-progress" +import { EstimatedProgress } from "./estimated-progress" + +/** Above this many items, a single-shot generation shows an estimated progress + * bar (E14) rather than a bare spinner. */ +const LARGE_GENERATION = 10 /** Controlled edit dialog for a quiz's title/description (used by row menus). */ export function EditQuizDialog({ @@ -151,6 +156,8 @@ export function GenerateQuizDialog({ const [complete, setComplete] = React.useState(false) const [jobId, setJobId] = React.useState(null) const [createdQuizId, setCreatedQuizId] = React.useState(null) + // >0 while a large single-shot generation is running → estimated progress bar. + const [estCount, setEstCount] = React.useState(0) if (!aiAvailable) return null @@ -159,6 +166,7 @@ export function GenerateQuizDialog({ setCreatedQuizId(null) setPending(false) setComplete(false) + setEstCount(0) } async function onSubmit(e: React.FormEvent) { @@ -178,9 +186,11 @@ export function GenerateQuizDialog({ setCreatedQuizId(res.quizId) setJobId(res.jobId) } else { + const count = Number(form.get("count")) + if (count >= LARGE_GENERATION) setEstCount(count) const result = await generateQuiz({ moduleId: moduleId || null, - count: Number(form.get("count")), + count, topics: String(form.get("topics") || "") || undefined, mixed, }) @@ -191,6 +201,7 @@ export function GenerateQuizDialog({ toast.error(error instanceof Error ? error.message : String(error)) } finally { setPending(false) + setEstCount(0) } } @@ -231,6 +242,8 @@ export function GenerateQuizDialog({
      + ) : estCount > 0 ? ( + ) : (
    + )} +
    + setWeek(e.target.value)} + aria-label={t("weekLabel")} + className="h-8 w-40" + /> + setHours(e.target.value)} + placeholder={t("hoursLabel")} + aria-label={t("hoursLabel")} + className="h-8 w-24" + /> + +
    + + ) +} diff --git a/src/components/plan/module-plan-prefs.tsx b/src/components/plan/module-plan-prefs.tsx new file mode 100644 index 0000000..5335802 --- /dev/null +++ b/src/components/plan/module-plan-prefs.tsx @@ -0,0 +1,205 @@ +"use client" + +import * as React from "react" +import { BookOpen } from "lucide-react" +import { useTranslations } from "next-intl" +import { useActionErrorToast } from "@/components/action-error-toast" +import { useRouter } from "@/i18n/navigation" +import { updateModulePlanPrefs } from "@/app/[locale]/(app)/plan/plan-task-actions" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Switch } from "@/components/ui/switch" +import { cn } from "@/lib/utils" + +export type ModulePlanPrefsValue = { + active: boolean + weight: number + weeklyHoursTarget: number | null + phase: number + preferredWeekdays: number[] | null +} + +// Monday-first display order; values are JS weekday numbers. +const WEEKDAYS = [1, 2, 3, 4, 5, 6, 0] as const + +/** + * D1: shared module-plan prefs (active / weight / weekly-hours / phase / + * preferred weekdays). Standardised on SAVE-ON-CHANGE — every field persists + * immediately via `updateModulePlanPrefs`. Rendered by both the strategy board + * (`layout="row"`, per-module row) and the module plan tab (`layout="card"`). + */ +export function ModulePlanPrefs({ + moduleId, + value, + layout, + moduleName, +}: { + moduleId: string + value: ModulePlanPrefsValue + layout: "row" | "card" + /** Shown as the row label in `layout="row"`. */ + moduleName?: string +}) { + const t = useTranslations("plan") + const showError = useActionErrorToast() + const router = useRouter() + const [active, setActive] = React.useState(value.active) + const [weight, setWeight] = React.useState(String(value.weight)) + const [hours, setHours] = React.useState( + value.weeklyHoursTarget == null ? "" : String(value.weeklyHoursTarget) + ) + const [phase, setPhase] = React.useState(value.phase) + const [weekdays, setWeekdays] = React.useState(value.preferredWeekdays ?? []) + + async function save(patch: Record) { + try { + await updateModulePlanPrefs(moduleId, patch) + router.refresh() + } catch (error) { + showError(error) + } + } + + const inputSize = layout === "row" ? "h-8" : "h-9" + const dayBtnSize = layout === "row" ? "size-7" : "size-8" + + const activeControl = ( + { + setActive(on) + void save({ active: on }) + }} + aria-label={t("prefs.active")} + /> + ) + + const weightField = ( + + ) + + const hoursField = ( + + ) + + const phaseField = ( + + ) + + const weekdayRow = ( +
    + {t("prefs.weekdays")} + {WEEKDAYS.map((d) => { + const on = weekdays.includes(d) + return ( + + ) + })} +
    + ) + + if (layout === "row") { + return ( +
    +
    + {activeControl} + + + {moduleName} + + {/* Stack the fields under sm so they don't cram on phones (D4). */} +
    + {weightField} + {hoursField} + {phaseField} +
    +
    +
    {weekdayRow}
    +
    + ) + } + + return ( + + + {t("prefs.title")} + + +
    + {activeControl} + +
    +
    + {weightField} + {hoursField} + {phaseField} +
    +

    {t("prefs.weightHint")}

    +
    + + {weekdayRow} +
    +
    +
    + ) +} diff --git a/src/components/plan/module-plan-view.tsx b/src/components/plan/module-plan-view.tsx index d344912..6f66e85 100644 --- a/src/components/plan/module-plan-view.tsx +++ b/src/components/plan/module-plan-view.tsx @@ -20,14 +20,15 @@ import { CalendarClock, Clock, GripVertical, + ListChecks, Loader2, Pencil, Plus, - Sparkles, Trash2, } from "lucide-react" import { useFormatter, useTranslations } from "next-intl" import { toast } from "sonner" +import { useActionErrorToast } from "@/components/action-error-toast" import { Link, useRouter } from "@/i18n/navigation" import { createPlanTask, @@ -35,10 +36,12 @@ import { generateModuleTasks, reorderPlanTasks, togglePlanTask, - updateModulePlanPrefs, updatePlanTask, } from "@/app/[locale]/(app)/plan/plan-task-actions" +import { AiBadge } from "@/components/ai/ai-badge" import { FormDialog } from "@/components/form-dialog" +import { ModulePlanPrefs, type ModulePlanPrefsValue } from "@/components/plan/module-plan-prefs" +import { SetupChecklist, type SetupStep } from "@/components/plan/setup-checklist" import { ConfirmDeleteDialog } from "@/components/studies/confirm-delete-dialog" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" @@ -46,7 +49,6 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Checkbox } from "@/components/ui/checkbox" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" -import { Switch } from "@/components/ui/switch" import { Textarea } from "@/components/ui/textarea" import { cn } from "@/lib/utils" @@ -59,14 +61,7 @@ export type PlanTaskData = { goalId: string | null done: boolean scheduled: boolean -} - -export type ModulePlanPrefs = { - active: boolean - weight: number - weeklyHoursTarget: number | null - phase: number - preferredWeekdays: number[] | null + aiGenerated: boolean } export type UpcomingSession = { @@ -83,21 +78,30 @@ export type GoalOption = { id: string; type: string; title: string | null } export function ModulePlanView({ moduleId, semesterId, + basePath, prefs, hasGoals, + hasExamGoal, + hasOutline, goals, tasks, sessions, + setupSteps = [], }: { moduleId: string semesterId: string - prefs: ModulePlanPrefs + basePath: string + prefs: ModulePlanPrefsValue hasGoals: boolean + hasExamGoal: boolean + hasOutline: boolean goals: GoalOption[] tasks: PlanTaskData[] sessions: UpcomingSession[] + setupSteps?: SetupStep[] }) { const t = useTranslations("plan") + const showError = useActionErrorToast() const router = useRouter() const [generating, setGenerating] = React.useState(false) @@ -108,14 +112,22 @@ export function ModulePlanView({ toast.success(t("tasks.generated", { count: res.created })) router.refresh() } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)) + showError(error) } finally { setGenerating(false) } } + // A13: an exam module with neither outline topics nor tasks gets nudged to + // build (or derive) an outline first, so generation has something to work on. + const showOutlineNudge = tasks.length === 0 && hasExamGoal && !hasOutline + return (
    + {setupSteps.some((s) => !s.done) && ( + + )} + {t("tasks.title")} @@ -124,7 +136,7 @@ export function ModulePlanView({ {generating ? ( ) : ( - + )} {t("tasks.generate")} @@ -132,7 +144,21 @@ export function ModulePlanView({ {tasks.length === 0 ? ( -

    {t("tasks.empty")}

    + showOutlineNudge ? ( +
    +

    {t("tasks.outlineNudge")}

    + +
    + ) : ( +

    {t("tasks.empty")}

    + ) ) : ( )} @@ -140,7 +166,7 @@ export function ModulePlanView({
    - +
    @@ -158,6 +184,7 @@ function TaskList({ tasks: PlanTaskData[] goals: GoalOption[] }) { + const showError = useActionErrorToast() const [list, setList] = React.useState(tasks) const [prev, setPrev] = React.useState(tasks) const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })) @@ -182,7 +209,7 @@ function TaskList({ next.map((i) => i.id) ) } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)) + showError(error) setList(tasks) } } @@ -230,6 +257,7 @@ function TaskRow({ dragHandle: Record }) { const t = useTranslations("plan") + const showError = useActionErrorToast() const format = useFormatter() const router = useRouter() const [editOpen, setEditOpen] = React.useState(false) @@ -240,7 +268,7 @@ function TaskRow({ await togglePlanTask(task.id, !task.done) router.refresh() } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)) + showError(error) } } @@ -260,6 +288,7 @@ function TaskRow({ {task.title} + {task.aiGenerated && } {task.scheduled && ( @@ -410,6 +439,7 @@ function EditTaskDialog({ function AddTaskForm({ moduleId, goals }: { moduleId: string; goals: GoalOption[] }) { const t = useTranslations("plan") + const showError = useActionErrorToast() const router = useRouter() const [pending, setPending] = React.useState(false) @@ -430,7 +460,7 @@ function AddTaskForm({ moduleId, goals }: { moduleId: string; goals: GoalOption[ el.reset() router.refresh() } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)) + showError(error) } finally { setPending(false) } @@ -469,142 +499,6 @@ function AddTaskForm({ moduleId, goals }: { moduleId: string; goals: GoalOption[ ) } -// ---- prefs form ----------------------------------------------------------------- - -// Monday-first display order; values are JS weekday numbers. -const WEEKDAYS = [1, 2, 3, 4, 5, 6, 0] as const - -function PrefsForm({ - moduleId, - semesterId, - prefs, -}: { - moduleId: string - semesterId: string - prefs: ModulePlanPrefs -}) { - const t = useTranslations("plan") - const router = useRouter() - const [pending, setPending] = React.useState(false) - const [active, setActive] = React.useState(prefs.active) - const [weight, setWeight] = React.useState(String(prefs.weight)) - const [weeklyHours, setWeeklyHours] = React.useState( - prefs.weeklyHoursTarget == null ? "" : String(prefs.weeklyHoursTarget) - ) - const [phase, setPhase] = React.useState(prefs.phase) - const [weekdays, setWeekdays] = React.useState(prefs.preferredWeekdays ?? []) - - function toggleWeekday(d: number) { - setWeekdays((cur) => (cur.includes(d) ? cur.filter((x) => x !== d) : [...cur, d])) - } - - async function onSave() { - setPending(true) - try { - await updateModulePlanPrefs(moduleId, { - active, - weight: Number(weight) || 1, - weeklyHoursTarget: weeklyHours === "" ? null : Number(weeklyHours), - phase, - preferredWeekdays: weekdays.length > 0 ? weekdays : null, - }) - toast.success(t("prefs.saved")) - router.refresh() - } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)) - } finally { - setPending(false) - } - } - - return ( - - - {t("prefs.title")} - - {t("prefs.linkToSemester")} → - - - -
    - - -
    -
    -
    - - setWeight(e.target.value)} - /> -
    -
    - - setWeeklyHours(e.target.value)} - /> -
    -
    - - -
    -
    -
    - -
    - {WEEKDAYS.map((d) => { - const on = weekdays.includes(d) - return ( - - ) - })} -
    -
    - -
    -
    - ) -} - // ---- upcoming sessions (read-only) ---------------------------------------------- function UpcomingSessions({ diff --git a/src/components/plan/plan-banners.tsx b/src/components/plan/plan-banners.tsx new file mode 100644 index 0000000..c5bcf03 --- /dev/null +++ b/src/components/plan/plan-banners.tsx @@ -0,0 +1,63 @@ +"use client" + +import { AlertTriangle, CalendarClock, Loader2, RefreshCw } from "lucide-react" +import { useTranslations } from "next-intl" +import { Button } from "@/components/ui/button" +import { useRecompute } from "@/components/plan/use-recompute" + +/** + * Plan banners shown above the semester plan: + * - staleness (A2): the plan is out of date after a mutation + * - catch-up (A10): N past sessions were missed (not done, still have open tasks) + * Both offer a one-click recompute with the A11 summary toast. + */ +export function PlanBanners({ + semesterId, + stale, + missedCount, +}: { + semesterId: string + stale: boolean + missedCount: number +}) { + const t = useTranslations("plan") + const { recompute, computing } = useRecompute(semesterId) + + if (!stale && missedCount <= 0) return null + + return ( +
    + {missedCount > 0 && ( +
    + + {t("catchUp.message", { count: missedCount })} + +
    + )} + {stale && ( +
    + + {t("staleness.message")} + +
    + )} +
    + ) +} diff --git a/src/components/plan/setup-checklist.tsx b/src/components/plan/setup-checklist.tsx new file mode 100644 index 0000000..e705288 --- /dev/null +++ b/src/components/plan/setup-checklist.tsx @@ -0,0 +1,135 @@ +"use client" + +import * as React from "react" +import { ArrowRight, Check, ListChecks, X } from "lucide-react" +import { useTranslations } from "next-intl" +import { Link } from "@/i18n/navigation" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { cn } from "@/lib/utils" + +export type SetupStepKey = "module" | "examDate" | "availability" | "tasks" | "plan" + +export type SetupStep = { + key: SetupStepKey + done: boolean + /** Where the "do this" affordance links to. */ + href: string +} + +/** + * A1 + E15: friendly, dismissible setup checklist. Step completion is derived + * server-side and passed in as booleans; each incomplete step links to the + * surface that completes it. Renders on the module plan tab (subset), the + * strategy board first-run state and as an onboarding follow-up CTA. + * + * Auto-hides once every passed step is done, or when the user dismisses it + * (remembered per `storageKey` in localStorage). + */ +export function SetupChecklist({ + steps, + storageKey, +}: { + steps: SetupStep[] + storageKey: string +}) { + const t = useTranslations("plan.checklist") + const key = `setup-checklist:${storageKey}` + + // Read the persisted dismissal without a setState-in-effect (SSR-safe: the + // server snapshot is always "not dismissed", the client hydrates from storage). + const persisted = React.useSyncExternalStore( + (cb) => { + window.addEventListener("storage", cb) + return () => window.removeEventListener("storage", cb) + }, + () => { + try { + return localStorage.getItem(key) === "1" + } catch { + return false + } + }, + () => false + ) + const [dismissedNow, setDismissedNow] = React.useState(false) + const dismissed = persisted || dismissedNow + + function dismiss() { + setDismissedNow(true) + try { + localStorage.setItem(key, "1") + } catch { + // ignore persistence failures + } + } + + const allDone = steps.every((s) => s.done) + if (dismissed || steps.length === 0 || allDone) return null + + const nextStep = steps.find((s) => !s.done) + const doneCount = steps.filter((s) => s.done).length + + return ( + + +
    + + + +
    + {t("title")} +

    + {t("progress", { done: doneCount, total: steps.length })} +

    +
    +
    + +
    + + {steps.map((step, i) => { + const isNext = step === nextStep + return ( +
    + + {step.done ? : i + 1} + +
    +

    + {t(`steps.${step.key}.title`)} +

    +

    {t(`steps.${step.key}.desc`)}

    +
    + {!step.done && ( + + )} +
    + ) + })} +
    +
    + ) +} diff --git a/src/components/plan/strategy-board.tsx b/src/components/plan/strategy-board.tsx index 66b0236..02bf922 100644 --- a/src/components/plan/strategy-board.tsx +++ b/src/components/plan/strategy-board.tsx @@ -1,18 +1,18 @@ "use client" import * as React from "react" -import { AlertTriangle, BookOpen, CalendarClock, Loader2, Sparkles } from "lucide-react" +import { AlertTriangle, CalendarClock, Loader2, RefreshCw } from "lucide-react" import { useFormatter, useTranslations } from "next-intl" -import { toast } from "sonner" +import { useActionErrorToast } from "@/components/action-error-toast" import { useRouter } from "@/i18n/navigation" -import { updateModulePlanPrefs } from "@/app/[locale]/(app)/plan/plan-task-actions" -import { recomputeSchedule, toggleSession } from "@/app/[locale]/(app)/plan/schedule-actions" +import { toggleSession } from "@/app/[locale]/(app)/plan/schedule-actions" import type { ScheduleWarning } from "@/lib/plan/scheduler" +import { ModulePlanPrefs } from "@/components/plan/module-plan-prefs" +import { SetupChecklist, type SetupStep } from "@/components/plan/setup-checklist" +import { useRecompute } from "@/components/plan/use-recompute" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Checkbox } from "@/components/ui/checkbox" -import { Input } from "@/components/ui/input" -import { Switch } from "@/components/ui/switch" import { cn } from "@/lib/utils" export type StrategyModule = { @@ -35,9 +35,6 @@ export type PreviewSession = { taskCount: number } -// Monday-first display order; values are JS weekday numbers. -const WEEKDAYS = [1, 2, 3, 4, 5, 6, 0] as const - function isoWeekKey(dateStr: string): string { const d = new Date(dateStr) const day = (d.getDay() + 6) % 7 @@ -60,15 +57,16 @@ export function StrategyBoard({ hasAvailability, modules, sessions, + setupSteps = [], }: { semesterId: string hasAvailability: boolean modules: StrategyModule[] sessions: PreviewSession[] + setupSteps?: SetupStep[] }) { const t = useTranslations("plan") - const router = useRouter() - const [computing, setComputing] = React.useState(false) + const { recompute, computing } = useRecompute(semesterId) const [warnings, setWarnings] = React.useState([]) const nameById = React.useMemo( @@ -77,29 +75,24 @@ export function StrategyBoard({ ) async function onCompute() { - setComputing(true) - try { - const res = await recomputeSchedule(semesterId) - setWarnings(res.warnings) - toast.success(t("strategy.computed", { count: res.sessions ?? 0 })) - router.refresh() - } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)) - } finally { - setComputing(false) - } + setWarnings(await recompute()) } + const showChecklist = sessions.length === 0 && setupSteps.some((s) => !s.done) + return ( {t("strategy.title")} + {showChecklist && ( + + )} {!hasAvailability && (

    {t("strategy.needAvailability")}

    )} @@ -108,7 +101,19 @@ export function StrategyBoard({ ) : (
    {modules.map((m) => ( - + ))}
    )} @@ -122,7 +127,7 @@ export function StrategyBoard({ > - {t(`strategy.warning.${w.kind}`)} + {t(`warningText.${w.kind}`)} {w.moduleId && nameById.has(w.moduleId) && ( · {nameById.get(w.moduleId)} )} @@ -138,114 +143,9 @@ export function StrategyBoard({ ) } -function ModuleRow({ module: m }: { module: StrategyModule }) { - const t = useTranslations("plan") - const router = useRouter() - const [active, setActive] = React.useState(m.active) - const [weight, setWeight] = React.useState(String(m.weight)) - const [hours, setHours] = React.useState(m.weeklyHoursTarget == null ? "" : String(m.weeklyHoursTarget)) - const [phase, setPhase] = React.useState(m.phase) - const [weekdays, setWeekdays] = React.useState(m.preferredWeekdays ?? []) - - async function save(patch: Record) { - try { - await updateModulePlanPrefs(m.moduleId, patch) - router.refresh() - } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)) - } - } - - return ( -
    -
    - { - setActive(on) - void save({ active: on }) - }} - aria-label={t("prefs.active")} - /> - - - {m.name} - -
    - - - -
    -
    -
    - {t("prefs.weekdays")} - {WEEKDAYS.map((d) => { - const on = weekdays.includes(d) - return ( - - ) - })} -
    -
    - ) -} - function SessionPreview({ sessions }: { sessions: PreviewSession[] }) { const t = useTranslations("plan") + const showError = useActionErrorToast() const format = useFormatter() const router = useRouter() @@ -260,7 +160,7 @@ function SessionPreview({ sessions }: { sessions: PreviewSession[] }) { await toggleSession(id, done) router.refresh() } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)) + showError(error) } } diff --git a/src/components/plan/today-plan-card.tsx b/src/components/plan/today-plan-card.tsx index 21ef37f..cfa322b 100644 --- a/src/components/plan/today-plan-card.tsx +++ b/src/components/plan/today-plan-card.tsx @@ -1,8 +1,9 @@ "use client" +import * as React from "react" import { CalendarClock } from "lucide-react" import { useTranslations } from "next-intl" -import { toast } from "sonner" +import { useActionErrorToast } from "@/components/action-error-toast" import { Link, useRouter } from "@/i18n/navigation" import { toggleSession } from "@/app/[locale]/(app)/plan/schedule-actions" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" @@ -22,17 +23,22 @@ export type TodayPlanSession = { /** Dashboard card: today's plan sessions, each checkable, with task titles. */ export function TodayPlanCard({ items }: { items: TodayPlanSession[] }) { const t = useTranslations("semesterPlan") + const showError = useActionErrorToast() const router = useRouter() + const [pending, startTransition] = React.useTransition() if (items.length === 0) return null - async function onToggle(id: string, done: boolean) { - try { - await toggleSession(id, done) - router.refresh() - } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)) - } + function onToggle(id: string, done: boolean) { + // Disable during flight so rapid taps can't double-fire (D13). + startTransition(async () => { + try { + await toggleSession(id, done) + router.refresh() + } catch (error) { + showError(error) + } + }) } return ( @@ -62,7 +68,8 @@ export function TodayPlanCard({ items }: { items: TodayPlanSession[] }) {
    void onToggle(item.id, Boolean(on))} + disabled={pending} + onCheckedChange={(on) => onToggle(item.id, Boolean(on))} /> {item.moduleName ?? ""} diff --git a/src/components/plan/use-recompute.ts b/src/components/plan/use-recompute.ts new file mode 100644 index 0000000..c61b005 --- /dev/null +++ b/src/components/plan/use-recompute.ts @@ -0,0 +1,41 @@ +"use client" + +import * as React from "react" +import { useTranslations } from "next-intl" +import { toast } from "sonner" +import { useActionErrorToast } from "@/components/action-error-toast" +import { useRouter } from "@/i18n/navigation" +import { recomputeSchedule } from "@/app/[locale]/(app)/plan/schedule-actions" +import type { ScheduleWarning } from "@/lib/plan/scheduler" + +/** + * Shared "recompute the plan" action for the strategy board and the plan + * banners. Runs {@link recomputeSchedule}, shows the A11 summary toast + * ("Plan aktualisiert: N Sitzungen, M Warnungen") and refreshes. Returns the + * warnings so a caller can render them inline. + */ +export function useRecompute(semesterId: string) { + const t = useTranslations("plan") + const showError = useActionErrorToast() + const router = useRouter() + const [computing, setComputing] = React.useState(false) + + const recompute = React.useCallback(async (): Promise => { + setComputing(true) + try { + const res = await recomputeSchedule(semesterId) + const warnings = res.warnings ?? [] + const sessions = res.sessions ?? 0 + toast.success(t("recompute.summary", { sessions, warnings: warnings.length })) + router.refresh() + return warnings + } catch (error) { + showError(error) + return [] + } finally { + setComputing(false) + } + }, [semesterId, t, router, showError]) + + return { recompute, computing } +} diff --git a/src/components/settings/notification-settings.tsx b/src/components/settings/notification-settings.tsx index a7619a2..4179d9e 100644 --- a/src/components/settings/notification-settings.tsx +++ b/src/components/settings/notification-settings.tsx @@ -173,13 +173,24 @@ export function NotificationSettings({ aria-label={`${t(`categories.${category}`)} ${t("email")}`} /> toggle(category, "push", on)} aria-label={`${t(`categories.${category}`)} ${t("push")}`} /> ))}
    + {/* Contextual prompt: push toggles are inert until the browser is + subscribed (E23). */} + {pushSubscribed === false && ( +

    + {t("pushInactiveHint")} +

    + )}

    {t("emailHint", { email })}

    {pushSubscribed === null ? ( diff --git a/src/components/studies/goal-dialog.tsx b/src/components/studies/goal-dialog.tsx index 907b9da..46471fa 100644 --- a/src/components/studies/goal-dialog.tsx +++ b/src/components/studies/goal-dialog.tsx @@ -318,6 +318,7 @@ export function GoalDialog({ max={1} defaultValue={cfg.bonus?.minCompletedShare ?? ""} /> +

    {t("bonus.minCompletedShareHint")}

    )} diff --git a/src/components/studies/module-dialog.tsx b/src/components/studies/module-dialog.tsx index 45c7a21..eb636e0 100644 --- a/src/components/studies/module-dialog.tsx +++ b/src/components/studies/module-dialog.tsx @@ -1,18 +1,11 @@ "use client" import * as React from "react" -import { Loader2, Pencil, Plus } from "lucide-react" +import { Pencil, Plus } from "lucide-react" import { useTranslations } from "next-intl" import { toast } from "sonner" import { useRouter } from "@/i18n/navigation" -import { Button } from "@/components/ui/button" -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog" +import { FormDialog } from "@/components/form-dialog" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { @@ -74,16 +67,8 @@ export function ModuleDialog({ const t = useTranslations("studies") const tDialog = useTranslations("studies.moduleDialog") const tGoals = useTranslations("goals") - const tCommon = useTranslations("common") const router = useRouter() - const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false) const controlled = controlledOpen !== undefined - const open = controlled ? controlledOpen : uncontrolledOpen - const setOpen = (v: boolean) => { - if (controlled) onOpenChange?.(v) - else setUncontrolledOpen(v) - } - const [pending, setPending] = React.useState(false) const [status, setStatus] = React.useState(module?.status ?? "planned") const [icon, setIcon] = React.useState(module?.icon ?? null) const [color, setColor] = React.useState(module?.color ?? null) @@ -97,9 +82,7 @@ export function ModuleDialog({ failed: t("module.statusFailed"), } - async function onSubmit(e: React.FormEvent) { - e.preventDefault() - const form = new FormData(e.currentTarget) + async function onSubmit(form: FormData) { const numOrNull = (key: string) => form.get(key) !== null && String(form.get(key)).trim() !== "" ? Number(form.get(key)) @@ -113,45 +96,35 @@ export function ModuleDialog({ icon, color, } - setPending(true) - try { - if (isEdit) await updateModule(module!.id!, payload) - else await createModule(semesterId, { ...payload, goalTypes }) - toast.success(isEdit ? t("updated") : t("created")) - setOpen(false) - router.refresh() - } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)) - } finally { - setPending(false) - } + if (isEdit) await updateModule(module!.id!, payload) + else await createModule(semesterId, { ...payload, goalTypes }) + toast.success(isEdit ? t("updated") : t("created")) + router.refresh() } const previewColor = getModuleColorClasses(color) return ( - - {!controlled && ( - : - - - - - + ) } diff --git a/src/components/studies/module-goals-card.tsx b/src/components/studies/module-goals-card.tsx index 69178f7..c99dc14 100644 --- a/src/components/studies/module-goals-card.tsx +++ b/src/components/studies/module-goals-card.tsx @@ -21,6 +21,7 @@ import { GoalCard, type BonusProgressDTO, type GoalCardData, + type GoalReadinessDTO, } from "@/components/learn/goal-card" const GOAL_ICON: Record> = { @@ -54,6 +55,7 @@ export function ModuleGoalsCard({ gradingSystem, stats, bonus, + readiness, }: { moduleId: string basePath: string @@ -61,6 +63,7 @@ export function ModuleGoalsCard({ gradingSystem: GradingSystem stats: { dueCards: number; lastQuizScore: number | null } bonus: BonusProgressDTO | null + readiness?: GoalReadinessDTO }) { const t = useTranslations("goals") const [dialog, setDialog] = React.useState({ mode: "closed" }) @@ -138,6 +141,7 @@ export function ModuleGoalsCard({ gradingSystem={gradingSystem} stats={stats} bonus={bonus} + readiness={readiness} /> ))} {dialogEl} diff --git a/src/components/studies/module-tabs.tsx b/src/components/studies/module-tabs.tsx index c771d10..c0ea262 100644 --- a/src/components/studies/module-tabs.tsx +++ b/src/components/studies/module-tabs.tsx @@ -43,38 +43,42 @@ export function ModuleTabs({ } return ( - + ) } diff --git a/src/components/ui/empty-state.tsx b/src/components/ui/empty-state.tsx new file mode 100644 index 0000000..c1719ee --- /dev/null +++ b/src/components/ui/empty-state.tsx @@ -0,0 +1,41 @@ +import type { LucideIcon } from "lucide-react" +import { cn } from "@/lib/utils" + +/** + * Shared empty-state: an optional icon, a title, an optional description and an + * optional action (button / link). Keeps the app's "nothing here yet" surfaces + * visually consistent instead of ad-hoc bare `

    ` tags (F15). + */ +export function EmptyState({ + icon: Icon, + title, + description, + action, + className, +}: { + icon?: LucideIcon + title: string + description?: string + action?: React.ReactNode + className?: string +}) { + return ( +

    + {Icon && ( + + + + )} +

    {title}

    + {description && ( +

    {description}

    + )} + {action &&
    {action}
    } +
    + ) +} diff --git a/src/components/writing/retry-thesis-button.tsx b/src/components/writing/retry-thesis-button.tsx index 1b1ac11..36490ec 100644 --- a/src/components/writing/retry-thesis-button.tsx +++ b/src/components/writing/retry-thesis-button.tsx @@ -3,7 +3,7 @@ import * as React from "react" import { Loader2, RotateCcw } from "lucide-react" import { useTranslations } from "next-intl" -import { toast } from "sonner" +import { useActionErrorToast } from "@/components/action-error-toast" import { useRouter } from "@/i18n/navigation" import { retryThesis } from "@/app/[locale]/(app)/thesis/actions" import { Button } from "@/components/ui/button" @@ -18,6 +18,7 @@ import { export function RetryThesisButton({ thesisId }: { thesisId: string }) { const t = useTranslations("thesis") const tCommon = useTranslations("common") + const showError = useActionErrorToast() const router = useRouter() const [open, setOpen] = React.useState(false) const [pending, setPending] = React.useState(false) @@ -29,7 +30,7 @@ export function RetryThesisButton({ thesisId }: { thesisId: string }) { setOpen(false) router.refresh() } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)) + showError(error) } finally { setPending(false) } diff --git a/src/components/writing/thesis-create.tsx b/src/components/writing/thesis-create.tsx index fec9374..219abb8 100644 --- a/src/components/writing/thesis-create.tsx +++ b/src/components/writing/thesis-create.tsx @@ -3,7 +3,7 @@ import * as React from "react" import { Loader2, Plus, Sparkles } from "lucide-react" import { useTranslations } from "next-intl" -import { toast } from "sonner" +import { useActionErrorToast } from "@/components/action-error-toast" import { useRouter } from "@/i18n/navigation" import { Button } from "@/components/ui/button" import { @@ -35,6 +35,7 @@ export function ThesisCreateDialog({ }) { const t = useTranslations("thesis") const tCommon = useTranslations("common") + const showError = useActionErrorToast() const router = useRouter() const [open, setOpen] = React.useState(false) const [pending, setPending] = React.useState(false) @@ -55,7 +56,7 @@ export function ThesisCreateDialog({ setOpen(false) router.refresh() } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)) + showError(error) } finally { setPending(false) } @@ -130,6 +131,7 @@ export function BrainstormDialog({ }) { const t = useTranslations("thesis.brainstormDialog") const tThesis = useTranslations("thesis") + const showError = useActionErrorToast() const router = useRouter() const [open, setOpen] = React.useState(false) const [pending, setPending] = React.useState(false) @@ -146,7 +148,7 @@ export function BrainstormDialog({ const result = await brainstormTopics(String(form.get("interests"))) setTopics(result) } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)) + showError(error) } finally { setPending(false) } @@ -160,7 +162,7 @@ export function BrainstormDialog({ setOpen(false) router.refresh() } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)) + showError(error) } finally { setAdopting(null) } diff --git a/src/components/writing/writing-milestone-row.tsx b/src/components/writing/writing-milestone-row.tsx index 8a6b1bb..359feb4 100644 --- a/src/components/writing/writing-milestone-row.tsx +++ b/src/components/writing/writing-milestone-row.tsx @@ -3,7 +3,7 @@ import * as React from "react" import { Loader2, MoreHorizontal, Pencil, Trash2 } from "lucide-react" import { useTranslations } from "next-intl" -import { toast } from "sonner" +import { useActionErrorToast } from "@/components/action-error-toast" import { useRouter } from "@/i18n/navigation" import { deleteWritingMilestone, @@ -31,6 +31,7 @@ export function WritingMilestoneRow({ }) { const t = useTranslations("writing") const tCommon = useTranslations("common") + const showError = useActionErrorToast() const router = useRouter() const [editOpen, setEditOpen] = React.useState(false) const [deleteOpen, setDeleteOpen] = React.useState(false) @@ -41,7 +42,7 @@ export function WritingMilestoneRow({ await toggleWritingMilestone(milestone.id, !milestone.done) router.refresh() } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)) + showError(error) } } @@ -57,7 +58,7 @@ export function WritingMilestoneRow({ setEditOpen(false) router.refresh() } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)) + showError(error) } finally { setPending(false) } diff --git a/src/components/writing/writing-workspace.tsx b/src/components/writing/writing-workspace.tsx index b47907b..573fc4f 100644 --- a/src/components/writing/writing-workspace.tsx +++ b/src/components/writing/writing-workspace.tsx @@ -4,6 +4,7 @@ import * as React from "react" import { BookMarked, CalendarPlus, Loader2, Plus, Sparkles } from "lucide-react" import { useTranslations } from "next-intl" import { toast } from "sonner" +import { useActionErrorToast } from "@/components/action-error-toast" import { useRouter } from "@/i18n/navigation" import type { WritingKind, WritingPhase, WritingVariant } from "@/db/schema/thesis" import { phasesFor } from "@/lib/writing/phases" @@ -72,6 +73,7 @@ export function WritingWorkspace(props: { const { project, variant, aiAvailable, semesters, requiresSources } = props const t = useTranslations("writing") const tCommon = useTranslations("common") + const showError = useActionErrorToast() const router = useRouter() const isTask = variant === "task" const phases = phasesFor(variant) @@ -93,7 +95,7 @@ export function WritingWorkspace(props: { await fn() router.refresh() } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)) + showError(error) } finally { setPending(null) } diff --git a/src/db/index.ts b/src/db/index.ts index e5c333f..c249d0c 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -8,7 +8,29 @@ const connectionString = // Reuse the connection across HMR reloads in development const globalForDb = globalThis as unknown as { dbClient?: ReturnType } -const client = globalForDb.dbClient ?? postgres(connectionString) +function intEnv(name: string, fallback: number): number { + const raw = process.env[name] + const n = raw ? Number(raw) : NaN + return Number.isFinite(n) && n > 0 ? n : fallback +} + +// Explicit pool bounds instead of postgres.js defaults: cap connections so the +// web + worker tiers don't collectively exhaust Postgres, reap idle sockets, and +// fail fast when the DB is unreachable. `max` defaults to 10 (per importer) and +// is env-tunable (a dedicated worker can raise it). Set DB_TRANSACTION_POOLER=1 +// behind a transaction-mode pooler (pgbouncer), which forbids prepared +// statements → `prepare: false`. +const client = + globalForDb.dbClient ?? + postgres(connectionString, { + max: intEnv("DB_POOL_MAX", 10), + idle_timeout: intEnv("DB_IDLE_TIMEOUT", 30), + connect_timeout: intEnv("DB_CONNECT_TIMEOUT", 10), + ...(process.env.DB_TRANSACTION_POOLER === "1" || + process.env.DB_TRANSACTION_POOLER === "true" + ? { prepare: false } + : {}), + }) if (process.env.NODE_ENV !== "production") globalForDb.dbClient = client export const db = drizzle(client, { schema }) diff --git a/src/db/schema/materials.ts b/src/db/schema/materials.ts index 44cdf19..711b81a 100644 --- a/src/db/schema/materials.ts +++ b/src/db/schema/materials.ts @@ -1,6 +1,7 @@ import { type AnyPgColumn, bigint, + customType, index, integer, jsonb, @@ -9,10 +10,15 @@ import { timestamp, uniqueIndex, } from "drizzle-orm/pg-core" -import { relations, sql } from "drizzle-orm" +import { relations, sql, type SQL } from "drizzle-orm" import { user } from "./auth" import { studyModule } from "./studies" +/** Postgres full-text search vector (generated from `text_content`). */ +const tsvector = customType<{ data: string }>({ + dataType: () => "tsvector", +}) + export type MaterialKind = "file" | "link" /** @@ -119,6 +125,14 @@ export const material = pgTable( chunksTotal: integer("chunks_total"), /** Number of leaf chunks already embedded (for resumable/progress display). */ chunksEmbedded: integer("chunks_embedded"), + /** Full-text search vector over `text_content`, for the command palette + * search (replaces the ILIKE seq-scan). Uses the 'german' config to match + * the query's `websearch_to_tsquery('german', …)` in the search route. + * `coalesce` guards the nullable column so the generated expression stays + * total. Mirrors material_chunk.contentTsv. */ + textContentTsv: tsvector("text_content_tsv").generatedAlwaysAs( + (): SQL => sql`to_tsvector('german', coalesce(${material.textContent}, ''))` + ), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }) .notNull() @@ -130,6 +144,7 @@ export const material = pgTable( index("material_moduleId_idx").on(t.moduleId), index("material_folderId_idx").on(t.folderId), index("material_contentHash_idx").on(t.userId, t.contentHash), + index("material_text_content_tsv_idx").using("gin", t.textContentTsv), ] ) diff --git a/src/db/schema/plan.ts b/src/db/schema/plan.ts index 9fb4166..0884b08 100644 --- a/src/db/schema/plan.ts +++ b/src/db/schema/plan.ts @@ -37,6 +37,14 @@ export type PlanTaskSource = { refId?: string } +/** + * What a scheduled session is FOR — drives the pre-exam consolidation window: + * `study` = learning new material, `review` = spaced repetition / mock exams, + * `cards` = FSRS-due flashcards + mistakes deck. Derived from the majority task + * category placed in the session. + */ +export type PlanSessionKind = "study" | "review" | "cards" + /** * Per-module planning preferences (the "content layer" knobs a student tweaks). * One row per module; the scheduler reads these to distribute time across @@ -84,6 +92,8 @@ export const planSession = pgTable( date: date("date").notNull(), startTime: text("start_time").notNull(), // "HH:mm" durationMinutes: integer("duration_minutes").notNull(), + /** What this block is for (study | review | cards) — majority task category. */ + kind: text("kind").$type().notNull().default("study"), /** Student-fixed → the scheduler keeps it as-is and plans around it. */ pinned: boolean("pinned").notNull().default(false), done: boolean("done").notNull().default(false), diff --git a/src/db/schema/semester-plan.ts b/src/db/schema/semester-plan.ts index 54e4f34..bc259e5 100644 --- a/src/db/schema/semester-plan.ts +++ b/src/db/schema/semester-plan.ts @@ -2,6 +2,7 @@ import { index, jsonb, pgTable, text, timestamp } from "drizzle-orm/pg-core" import { relations } from "drizzle-orm" import { user } from "./auth" import { semester } from "./studies" +import type { ScheduleWarning } from "@/lib/plan/scheduler" export type PlanAvailability = { /** Weekly study windows, weekday 0 (Sunday) – 6. */ @@ -37,6 +38,13 @@ export type SemesterPlanConfig = { sessionMinutes?: { min: number; max: number } } +/** + * Per-ISO-week study-hour overrides ("this week I only have 4h"). Keyed by the + * ISO-8601 week key ("YYYY-Www", e.g. "2026-W31"), value = hours for that week. + * The scheduler caps the whole plan's assigned minutes that week at hours×60. + */ +export type WeekOverrides = Record + /** One AI-generated study plan per semester, based on the user's availability, * exam dates and assignment deadlines. */ export const semesterPlan = pgTable( @@ -55,6 +63,12 @@ export const semesterPlan = pgTable( availability: jsonb("availability").$type().notNull(), /** Scheduler tuning; null → default (2 sessions/day, 45–180 min). */ config: jsonb("config").$type(), + /** Per-ISO-week hour overrides ({"2026-W31": 4}). null/absent → no override. */ + weekOverrides: jsonb("week_overrides").$type(), + /** Set when a mutation invalidated the plan; null = fresh (up to date). */ + staleAt: timestamp("stale_at", { withTimezone: true }), + /** Warnings from the last successful recompute (readiness/UI surface). */ + lastWarnings: jsonb("last_warnings").$type(), generatedAt: timestamp("generated_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }, diff --git a/src/db/schema/studies.ts b/src/db/schema/studies.ts index 8e89bb0..25e8dae 100644 --- a/src/db/schema/studies.ts +++ b/src/db/schema/studies.ts @@ -65,6 +65,9 @@ export type GoalConfig = { withPresentation?: boolean /** presentation: talk length in minutes. */ durationMinutes?: number + /** exam/oral_exam: explicit consolidation window length in days (overrides + * the derived clamp(round(0.25 × daysUntil), 3, 14)). */ + reviewDays?: number | null } /** @@ -271,6 +274,8 @@ export const studyEvent = pgTable( .notNull() .references(() => user.id, { onDelete: "cascade" }), moduleId: text("module_id").references(() => studyModule.id, { onDelete: "set null" }), + /** For the idempotent exam-event sync: the module goal this event mirrors. */ + goalId: text("goal_id").references(() => moduleGoal.id, { onDelete: "cascade" }), type: text("type").$type().notNull().default("other"), title: text("title").notNull(), startsAt: timestamp("starts_at", { withTimezone: true }).notNull(), @@ -288,11 +293,16 @@ export const studyEvent = pgTable( recurrenceWeekdays: jsonb("recurrence_weekdays").$type(), /** For "custom": repeat every N weeks (1–4), anchored at startsAt's week. */ recurrenceInterval: integer("recurrence_interval"), + /** ISO dates (yyyy-mm-dd) of recurring occurrences deleted individually. + * `expandOccurrences` skips these — lets a single instance be removed + * without deleting the whole series. */ + skipDates: jsonb("skip_dates").$type(), ...timestamps, }, (t) => [ index("event_userId_idx").on(t.userId), index("event_startsAt_idx").on(t.startsAt), + index("event_goalId_idx").on(t.goalId), ] ) @@ -364,4 +374,8 @@ export const studyEventRelations = relations(studyEvent, ({ one }) => ({ fields: [studyEvent.moduleId], references: [studyModule.id], }), + goal: one(moduleGoal, { + fields: [studyEvent.goalId], + references: [moduleGoal.id], + }), })) diff --git a/src/db/schema/user-prefs.ts b/src/db/schema/user-prefs.ts index f234246..9090905 100644 --- a/src/db/schema/user-prefs.ts +++ b/src/db/schema/user-prefs.ts @@ -20,5 +20,8 @@ export const userPrefs = pgTable("user_prefs", { preferredModel: text("preferred_model"), /** Weekly study-time goal in minutes; null = no goal set. */ weeklyGoalMinutes: integer("weekly_goal_minutes"), + /** Preferred UI/notification locale (e.g. "de", "en"); null = app default. + * Read by the reminder job to localize push/email. */ + locale: text("locale"), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }) diff --git a/src/lib/action-errors.test.ts b/src/lib/action-errors.test.ts new file mode 100644 index 0000000..ffcdf04 --- /dev/null +++ b/src/lib/action-errors.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest" +import de from "../../messages/de.json" +import en from "../../messages/en.json" +import { ACTION_ERROR_CODES, extractActionErrorCode } from "./action-errors" + +/** Collects every dotted leaf key path of a nested message object. */ +function keyPaths(obj: unknown, prefix = ""): string[] { + if (obj === null || typeof obj !== "object") return [prefix] + return Object.entries(obj as Record).flatMap(([k, v]) => + keyPaths(v, prefix ? `${prefix}.${k}` : k) + ) +} + +describe("action error codes", () => { + it("every ActionErrorCode has an errors. key in de and en", () => { + const deErrors = de.errors as Record + const enErrors = en.errors as Record + for (const code of ACTION_ERROR_CODES) { + expect(deErrors[code], `missing de errors.${code}`).toBeTruthy() + expect(enErrors[code], `missing en errors.${code}`).toBeTruthy() + } + }) + + it("errors namespaces contain no codes beyond the ActionErrorCode union", () => { + const known = new Set(ACTION_ERROR_CODES) + for (const key of Object.keys(de.errors)) expect(known.has(key), `de errors.${key}`).toBe(true) + for (const key of Object.keys(en.errors)) expect(known.has(key), `en errors.${key}`).toBe(true) + }) + + it("extractActionErrorCode parses ERR: tokens and ignores everything else", () => { + expect(extractActionErrorCode(new Error("ERR:GOAL_MAX_ATTEMPTS"))).toBe("GOAL_MAX_ATTEMPTS") + expect(extractActionErrorCode(new Error("ERR:LIMIT_EXCEEDED logged detail"))).toBe( + "LIMIT_EXCEEDED" + ) + expect(extractActionErrorCode(new Error("ERR:NOT_A_REAL_CODE"))).toBeNull() + expect(extractActionErrorCode(new Error("Some raw provider string"))).toBeNull() + expect(extractActionErrorCode("plain string")).toBeNull() + }) +}) + +describe("i18n de/en parity", () => { + it("de and en share an identical key structure", () => { + const deKeys = keyPaths(de).sort() + const enKeys = keyPaths(en).sort() + const onlyDe = deKeys.filter((k) => !enKeys.includes(k)) + const onlyEn = enKeys.filter((k) => !deKeys.includes(k)) + expect(onlyDe, "keys only in de").toEqual([]) + expect(onlyEn, "keys only in en").toEqual([]) + }) +}) diff --git a/src/lib/action-errors.ts b/src/lib/action-errors.ts new file mode 100644 index 0000000..7a6a8e0 --- /dev/null +++ b/src/lib/action-errors.ts @@ -0,0 +1,52 @@ +/** + * Stable, language-independent codes for user-facing server-action failures. + * + * Server actions throw `actionError("CODE")` (an `ERR:` token) instead of + * a human-readable, single-language string. The client maps the code to a + * localized message via {@link toastActionError}, so a German error never + * reaches an English user (and vice versa). Mirrors the `AI_ERROR:` + * contract already consumed in `src/components/ai/chat.tsx`. + * + * Every code here MUST have an `errors.` key in both `messages/de.json` + * and `messages/en.json` — enforced by `src/lib/action-errors.test.ts`. + */ +export const ACTION_ERROR_CODES = [ + "PLAN_NO_AVAILABILITY", + "PLAN_INVALID_GOAL", + "GOAL_MAX_ATTEMPTS", + "WRITING_NO_GOAL", + "WRITING_NO_DUE_DATE", + "THESIS_ACTIVE_EXISTS", + "THESIS_MAX_ATTEMPTS", + "AI_NO_MODEL", + "AI_SETUP_REQUIRED", + "LIMIT_EXCEEDED", + "MATERIAL_NAME_REQUIRED", + "FOLDER_DUPLICATE", + "GENERIC", +] as const + +export type ActionErrorCode = (typeof ACTION_ERROR_CODES)[number] + +/** + * Throws an Error whose message is a stable `ERR:` token. The optional + * `fallbackMessage` is appended for server logs / non-UI callers only — it is + * never shown to a user (the client resolves the code to a localized string). + */ +export function actionError(code: ActionErrorCode, fallbackMessage?: string): never { + throw new Error(`ERR:${code}${fallbackMessage ? ` ${fallbackMessage}` : ""}`) +} + +/** + * Extracts a known {@link ActionErrorCode} from a thrown error, or null. The + * code is the first whitespace-delimited token after the `ERR:` prefix, so an + * optional trailing fallback message doesn't break parsing. + */ +export function extractActionErrorCode(error: unknown): ActionErrorCode | null { + const message = error instanceof Error ? error.message : String(error) + if (!message.startsWith("ERR:")) return null + const code = message.slice(4).split(/\s/, 1)[0] + return (ACTION_ERROR_CODES as readonly string[]).includes(code) + ? (code as ActionErrorCode) + : null +} diff --git a/src/lib/ai/params.ts b/src/lib/ai/params.ts index c5b01e0..c97067f 100644 --- a/src/lib/ai/params.ts +++ b/src/lib/ai/params.ts @@ -33,3 +33,14 @@ export const UTILITY_PARAMS = { temperature: 0.3 } as const export function maxTokensForItems(count: number): number { return Math.min(8000, Math.max(1500, count * 400)) } + +/** + * Output-token budget for a single prose generation (an outline, a summary, + * source suggestions) that isn't a countable list of items. Gives the model + * enough room to finish long structured prose without truncating mid-answer, + * while still capping cost. Defaults to a value sized for a multi-section + * outline; pass a smaller cap for short utility prose. + */ +export function maxTokensForText(cap = 4000): number { + return Math.min(8000, Math.max(500, cap)) +} diff --git a/src/lib/ai/rag.ts b/src/lib/ai/rag.ts index c8a13d1..19cf385 100644 --- a/src/lib/ai/rag.ts +++ b/src/lib/ai/rag.ts @@ -7,6 +7,7 @@ import { getSetting } from "@/lib/settings" import { deleteFile, readStoredText, saveText } from "@/lib/storage" import { getEmbeddingModel, getLanguageModel, resolveModelForUser } from "./registry" import { recordAiAudit, runAi } from "./run" +import { isOverLimit } from "./usage" import { extractText } from "./extract" import { annDimensionFor, populateAnn } from "./ann" @@ -79,6 +80,18 @@ export async function processMaterial(materialId: string): Promise { return } + // Gate the (paid) embedding pass on the user's monthly AI cap. Ingest is + // user-initiated, so a user at their limit simply gets no embeddings until + // they're back under it — retryable via retryMaterialProcessing. Extraction + // already ran, so lexical (full-text) search still covers this material. + if (await isOverLimit(row.userId)) { + await setStatus(materialId, "skipped", { + extractionError: + "Monthly AI usage limit reached — embedding skipped. Retry when under the limit.", + }) + return + } + // Contextual retrieval: situate each chunk in its document (title + summary // when available) so ambiguous chunks retrieve better. const contextHeader = [row.name, row.summary].filter(Boolean).join(" — ").slice(0, 500) @@ -124,6 +137,9 @@ async function ensureExtractedText( const { resolveModelForUser } = await import("./registry") if (!(await resolveModelForUser(row.userId))) { skipReason = "No AI model configured — image text (OCR) was not extracted." + } else if (await isOverLimit(row.userId)) { + // OCR is a paid model call; gate it on the monthly cap (retryable). + skipReason = "Monthly AI usage limit reached — image OCR was skipped." } else { const { extractImageText } = await import("./media") text = await extractImageText(row.storagePath!, row.mimeType, row.userId) @@ -134,6 +150,9 @@ async function ensureExtractedText( if (!(await getTranscriptionModel(row.userId))) { skipReason = "No transcription model available — audio/video needs an OpenAI or Groq provider." + } else if (await isOverLimit(row.userId)) { + // Transcription is a paid model call; gate it on the monthly cap. + skipReason = "Monthly AI usage limit reached — transcription was skipped." } else { const { transcribeMedia } = await import("./media") text = await transcribeMedia(row.storagePath!, row.userId) @@ -349,6 +368,9 @@ async function hybridSearch( const ai = await getSetting("ai") const embeddingRef = ai?.defaultEmbeddingModel if (!embeddingRef) return [] + // Over the monthly cap, degrade gracefully: skip the (paid) query embedding + // and rerank and serve lexical-only results rather than hard-failing search. + const overLimit = await isOverLimit(userId) const pool = Math.max(limit * 4, 24) const base = and(eq(material.userId, userId), ...extraWhere) @@ -373,40 +395,47 @@ async function hybridSearch( // Vector (cosine) ranking — uses the HNSW ANN column when an admin has built // it for the active model, otherwise the dimensionless sequential scan. - const annDim = await annDimensionFor(embeddingRef) - const model = await getEmbeddingModel(embeddingRef, userId) - const { embedding } = await runAi( - { - userId, - model: embeddingRef, - feature: "embedding-query", - operation: "ai_embed", - audit: false, // retrieval happens constantly — ledger only, no audit spam - }, - () => embed({ model, value: query }) - ) - const vectorLiteral = `[${embedding.join(",")}]` - const embCol = annDim ? sql.raw("material_chunk.embedding_hnsw") : sql`${materialChunk.embedding}` - const distExpr = sql`${embCol} <=> ${vectorLiteral}::vector` - const vectorWhere = annDim - ? and(base, eq(materialChunk.embeddingModel, embeddingRef), sql`${embCol} IS NOT NULL`) - : and(base, eq(materialChunk.embeddingModel, embeddingRef)) - const vector = await db - .select({ - id: materialChunk.id, - content: materialChunk.content, - materialName: material.name, - materialId: material.id, - similarity: sql`1 - (${distExpr})`, - }) - .from(materialChunk) - .innerJoin(material, eq(materialChunk.materialId, material.id)) - .where(vectorWhere) - .orderBy(distExpr) - .limit(pool) + // Skipped entirely when over the monthly cap (see `overLimit`). + let vector: ScoredRow[] = [] + if (!overLimit) { + const annDim = await annDimensionFor(embeddingRef) + const model = await getEmbeddingModel(embeddingRef, userId) + const { embedding } = await runAi( + { + userId, + model: embeddingRef, + feature: "embedding-query", + operation: "ai_embed", + audit: false, // retrieval happens constantly — ledger only, no audit spam + }, + () => embed({ model, value: query }) + ) + const vectorLiteral = `[${embedding.join(",")}]` + const embCol = annDim + ? sql.raw("material_chunk.embedding_hnsw") + : sql`${materialChunk.embedding}` + const distExpr = sql`${embCol} <=> ${vectorLiteral}::vector` + const vectorWhere = annDim + ? and(base, eq(materialChunk.embeddingModel, embeddingRef), sql`${embCol} IS NOT NULL`) + : and(base, eq(materialChunk.embeddingModel, embeddingRef)) + vector = await db + .select({ + id: materialChunk.id, + content: materialChunk.content, + materialName: material.name, + materialId: material.id, + similarity: sql`1 - (${distExpr})`, + }) + .from(materialChunk) + .innerJoin(material, eq(materialChunk.materialId, material.id)) + .where(vectorWhere) + .orderBy(distExpr) + .limit(pool) + } // Optional precision pass: let the chat model re-rank a wider candidate set. - if (ai?.rerank) { + // Also skipped over the cap (the rerank call is a paid model invocation). + if (ai?.rerank && !overLimit) { const candidates = rrfFuse(vector, lexical, Math.min(pool, limit * 3)) if (candidates.length > limit) return rerankHits(userId, query, candidates, limit) return candidates diff --git a/src/lib/ai/registry.ts b/src/lib/ai/registry.ts index 303a3d8..c5b25e7 100644 --- a/src/lib/ai/registry.ts +++ b/src/lib/ai/registry.ts @@ -163,6 +163,52 @@ export async function isAiAvailable(): Promise { return models.length > 0 } +/** Provider types that authenticate against a hosted API and therefore need a + * key. Self-hosted/local endpoints (Ollama, generic OpenAI-compatible gateways) + * commonly accept requests without one, so a missing key isn't a dead-end. */ +const KEY_REQUIRED_TYPES: ReadonlySet = new Set([ + "anthropic", + "openai", + "google", + "mistral", + "groq", +]) + +/** + * True when a request for `provider` can authenticate: the provider type needs + * no key, a shared admin/env key is configured, or the user has stored their + * own BYOK key. Lets callers distinguish "AI is configured" from "this user can + * actually run it" and surface an actionable setup hint before a request + * dead-ends deep inside the SDK with a generic auth error (F4). + */ +export async function providerHasUsableKey( + provider: AiProvider, + userId: string +): Promise { + if (!KEY_REQUIRED_TYPES.has(provider.type)) return true + if (provider.apiKey) return true + const byok = await db.query.userAiKey.findFirst({ + where: and(eq(userAiKey.userId, userId), eq(userAiKey.providerId, provider.id)), + columns: { id: true }, + }) + return Boolean(byok) +} + +/** + * True when the user can authenticate a request for the given "providerId:modelId" + * ref (provider needs no key, shared key, or the user's BYOK key). Unknown + * provider/ref → false. + */ +export async function userHasUsableKeyForModel(ref: string, userId: string): Promise { + try { + const { providerId } = parseModelRef(ref) + const provider = await getProvider(providerId) + return await providerHasUsableKey(provider, userId) + } catch { + return false + } +} + /** * The model a user's AI requests should use: their preferred model (if still * available), otherwise the admin-configured global default. Null when no diff --git a/src/lib/ai/usage.ts b/src/lib/ai/usage.ts index 7326dba..724f7e8 100644 --- a/src/lib/ai/usage.ts +++ b/src/lib/ai/usage.ts @@ -3,6 +3,7 @@ import { and, eq, gte, sum } from "drizzle-orm" import { db } from "@/db" import { aiUsageLog } from "@/db/schema" import { getSetting } from "@/lib/settings" +import { actionError } from "@/lib/action-errors" export async function logUsage( userId: string, @@ -20,18 +21,14 @@ export async function logUsage( } /** - * Throws if the user exceeded the admin-configured monthly token limit. - * - * This is a soft limit by design: usage is recorded after a call finishes, so - * concurrent in-flight requests can collectively overshoot the cap by roughly - * one request each. The per-request bounds (message-size and history caps in - * the chat route) keep that overshoot small; a hard limit would need - * pessimistic reservations, which isn't worth the complexity here. + * Returns the user's month-to-date token usage and the configured cap. `limit` + * is 0 when no cap is set (unlimited). Shared by the throwing and non-throwing + * limit checks below. */ -export async function assertWithinLimit(userId: string): Promise { +async function monthlyUsage(userId: string): Promise<{ used: number; limit: number }> { const ai = await getSetting("ai") const limit = ai?.monthlyTokenLimitPerUser ?? 0 - if (limit === 0) return + if (limit === 0) return { used: 0, limit: 0 } const monthStart = new Date() monthStart.setDate(1) monthStart.setHours(0, 0, 0, 0) @@ -42,8 +39,32 @@ export async function assertWithinLimit(userId: string): Promise { }) .from(aiUsageLog) .where(and(eq(aiUsageLog.userId, userId), gte(aiUsageLog.createdAt, monthStart))) - const used = (row?.total ?? 0) + (row?.totalOut ?? 0) - if (used >= limit) { - throw new Error("Monthly token limit reached") + return { used: (row?.total ?? 0) + (row?.totalOut ?? 0), limit } +} + +/** + * Throws if the user exceeded the admin-configured monthly token limit. + * + * This is a soft limit by design: usage is recorded after a call finishes, so + * concurrent in-flight requests can collectively overshoot the cap by roughly + * one request each. The per-request bounds (message-size and history caps in + * the chat route) keep that overshoot small; a hard limit would need + * pessimistic reservations, which isn't worth the complexity here. + */ +export async function assertWithinLimit(userId: string): Promise { + const { used, limit } = await monthlyUsage(userId) + if (limit > 0 && used >= limit) { + actionError("LIMIT_EXCEEDED") } } + +/** + * Non-throwing variant of {@link assertWithinLimit} for background / best-effort + * AI paths (embedding, OCR/transcription, query embedding, rerank, chat-title + * generation) that must gate or degrade rather than surface an error. Returns + * `true` when the user is at/over their monthly cap. + */ +export async function isOverLimit(userId: string): Promise { + const { used, limit } = await monthlyUsage(userId) + return limit > 0 && used >= limit +} diff --git a/src/lib/audit.ts b/src/lib/audit.ts index b4f44ce..70def53 100644 --- a/src/lib/audit.ts +++ b/src/lib/audit.ts @@ -28,6 +28,14 @@ const ENTITY_TABLES: Record = { thesis: writingProject, } +/** + * entityTypes whose table has a direct `user_id` column. For these the undo + * mutation is additionally scoped by userId as defense-in-depth. flashcard and + * question have no direct userId (they are scoped transitively via deck/quiz), + * so they are intentionally excluded and matched by id only. + */ +const USER_SCOPED_ENTITIES = new Set(["deck", "quiz", "event", "material", "thesis"]) + export type LogAuditInput = { userId: string actor?: AuditActor @@ -102,9 +110,17 @@ export async function undoAudit(entryId: string, userId: string) { if (!table) throw new Error("Not undoable") const tableId = (table as unknown as { id: unknown }).id + const tableUserId = (table as unknown as { userId?: unknown }).userId + + // Scope the mutation by userId too when the table carries one, so an undo can + // never touch another user's row even if entityIds were to collide. + const scoped = (idMatch: ReturnType) => + USER_SCOPED_ENTITIES.has(entry.entityType) && tableUserId + ? and(idMatch, eq(tableUserId as never, userId as never)) + : idMatch if (entry.operation === "create") { - await db.delete(table).where(eq(tableId as never, entry.entityId as never)) + await db.delete(table).where(scoped(eq(tableId as never, entry.entityId as never))) } else if (entry.operation === "delete") { if (!entry.before) throw new Error("No snapshot") await db.insert(table).values(reviveRow(entry.before as Record) as never) @@ -114,7 +130,7 @@ export async function undoAudit(entryId: string, userId: string) { await db .update(table) .set(rest as never) - .where(eq(tableId as never, entry.entityId as never)) + .where(scoped(eq(tableId as never, entry.entityId as never))) } await db.update(auditLog).set({ undone: true }).where(eq(auditLog.id, entry.id)) diff --git a/src/lib/auth/index.ts b/src/lib/auth/index.ts index 5e2a4e5..8c9a546 100644 --- a/src/lib/auth/index.ts +++ b/src/lib/auth/index.ts @@ -9,7 +9,7 @@ import { db } from "@/db" import * as schema from "@/db/schema" import { env } from "@/lib/env" import { sendEmail } from "@/lib/email" -import { getSetting } from "@/lib/settings" +import { bustSettingsCache, getSetting } from "@/lib/settings" type DynamicAuthConfig = { registrationMode: "open" | "closed" | "invite" @@ -22,14 +22,30 @@ function buildAuth(config: DynamicAuthConfig) { const oidc = config.oidcProviders ?? [] const rpUrl = new URL(env.APP_URL) + // When registration is not open, a first-time social/OIDC login must NOT + // auto-create a user (better-auth would otherwise implicitly sign them up, + // bypassing the closed/invite gate that only covers email sign-up). + const restrictImplicitSignup = config.registrationMode !== "open" + const implicitSignup = restrictImplicitSignup ? { disableImplicitSignUp: true as const } : {} + const options = { appName: "StudyHelper", baseURL: env.APP_URL, secret: env.BETTER_AUTH_SECRET, database: drizzleAdapter(db, { provider: "pg", schema }), + session: { + expiresIn: 60 * 60 * 24 * 7, // 7 days + updateAge: 60 * 60 * 24, // refresh the session at most once per day + }, + rateLimit: { + enabled: true, + window: 60, + max: 30, + }, emailAndPassword: { enabled: true, disableSignUp: config.registrationMode === "closed", + minPasswordLength: 8, sendResetPassword: async ({ user, url }) => { await sendEmail({ to: user.email, @@ -39,8 +55,8 @@ function buildAuth(config: DynamicAuthConfig) { }, }, socialProviders: { - ...(social.github ? { github: social.github } : {}), - ...(social.google ? { google: social.google } : {}), + ...(social.github ? { github: { ...social.github, ...implicitSignup } } : {}), + ...(social.google ? { google: { ...social.google, ...implicitSignup } } : {}), }, hooks: { // Invite mode: sign-ups require a valid invite token (enforced server-side) @@ -78,10 +94,30 @@ function buildAuth(config: DynamicAuthConfig) { databaseHooks: { user: { create: { - before: async (user) => { - // First user becomes admin automatically - const [{ value: userCount }] = await db.select({ value: count() }).from(schema.user) - return { data: { ...user, role: userCount === 0 ? "admin" : "user" } } + // Never decide admin here: counting users before the row is inserted + // is racy — two concurrent first sign-ups both read 0 and both become + // admin. Create everyone as a normal user; the `after` hook promotes + // the first one atomically. + before: async (user) => ({ data: { ...user, role: "user" } }), + // Exactly-one-first-admin, race-free. `after` runs once the create + // transaction has committed (queueAfterTransactionHook), so the new + // row is visible here. A transaction-scoped advisory lock serializes + // concurrent first sign-ups: the first to acquire it sees 0 admins and + // promotes itself; the rest see 1 and no-op. + after: async (user) => { + await db.transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtext('studyhelper:first-admin'))`) + const [{ value: adminCount }] = await tx + .select({ value: count() }) + .from(schema.user) + .where(eq(schema.user.role, "admin")) + if (adminCount === 0) { + await tx + .update(schema.user) + .set({ role: "admin" }) + .where(eq(schema.user.id, user.id)) + } + }) }, }, }, @@ -101,6 +137,7 @@ function buildAuth(config: DynamicAuthConfig) { clientId: p.clientId, clientSecret: p.clientSecret, scopes: p.scopes, + ...implicitSignup, })), }), nextCookies(), // must be last @@ -139,6 +176,9 @@ export function getAuth(): Promise { /** Call after changing auth-related settings so the next request picks them up. */ export function bustAuthCache(): void { globalForAuth.authInstance = undefined + // The auth instance is built from cached settings; drop those too so the + // rebuild reads the just-written values instead of a stale 30s-TTL copy. + bustSettingsCache() } // For CLI schema generation see src/lib/auth/cli.ts. Runtime code must use diff --git a/src/lib/events/recurrence.test.ts b/src/lib/events/recurrence.test.ts index 2dbf51a..10f6013 100644 --- a/src/lib/events/recurrence.test.ts +++ b/src/lib/events/recurrence.test.ts @@ -71,6 +71,33 @@ describe("expandOccurrences", () => { expect(occ).toHaveLength(1) expect(occ[0].endsAt!.getTime() - occ[0].startsAt.getTime()).toBe(90 * 60 * 1000) }) + + it("skips individually-deleted occurrences via skipDates (E18)", () => { + const occ = expandOccurrences( + { ...base, recurrence: "weekly", skipDates: ["2026-04-20", "2026-05-04"] }, + new Date("2026-04-01"), + new Date("2026-05-05") + ) + expect(occ.map((o) => o.occurrenceDate)).toEqual(["2026-04-13", "2026-04-27"]) + }) + + it("leaves the series intact when skipDates is empty or null", () => { + const empty = expandOccurrences( + { ...base, recurrence: "weekly", skipDates: [] }, + new Date("2026-04-01"), + new Date("2026-04-28") + ) + expect(empty.map((o) => o.occurrenceDate)).toEqual(["2026-04-13", "2026-04-20", "2026-04-27"]) + }) + + it("can skip the first (non-instance) occurrence too", () => { + const occ = expandOccurrences( + { ...base, recurrence: "weekly", skipDates: ["2026-04-13"] }, + new Date("2026-04-01"), + new Date("2026-04-28") + ) + expect(occ.map((o) => o.occurrenceDate)).toEqual(["2026-04-20", "2026-04-27"]) + }) }) describe("custom recurrence", () => { diff --git a/src/lib/events/recurrence.ts b/src/lib/events/recurrence.ts index 7eb434e..6df1a06 100644 --- a/src/lib/events/recurrence.ts +++ b/src/lib/events/recurrence.ts @@ -9,6 +9,8 @@ export type RecurringEventLike = { recurrenceWeekdays?: number[] | null /** "custom" only: repeat every N weeks, anchored at startsAt's week. */ recurrenceInterval?: number | null + /** ISO dates (local) of individually-deleted occurrences to skip (E18). */ + skipDates?: string[] | null } export type Occurrence = { @@ -40,21 +42,25 @@ export function expandOccurrences( ): Occurrence[] { const duration = event.endsAt ? event.endsAt.getTime() - event.startsAt.getTime() : null + // Occurrences whose local date was individually deleted (E18) are skipped so + // the rest of the series survives. + const skip = event.skipDates?.length ? new Set(event.skipDates) : null const make = (startsAt: Date, isInstance: boolean): Occurrence => ({ startsAt, endsAt: duration != null ? new Date(startsAt.getTime() + duration) : null, occurrenceDate: toIsoDate(startsAt), isRecurrenceInstance: isInstance, }) + const notSkipped = (o: Occurrence) => !skip?.has(o.occurrenceDate) if (event.recurrence === "none" || !event.recurrence) { return event.startsAt <= to && (event.endsAt ?? event.startsAt) >= from - ? [make(event.startsAt, false)] + ? [make(event.startsAt, false)].filter(notSkipped) : [] } if (event.recurrence === "custom") { - return expandCustom(event, from, to, make) + return expandCustom(event, from, to, make).filter(notSkipped) } const stepWeeks = event.recurrence === "biweekly" ? 2 : 1 @@ -80,7 +86,7 @@ export function expandOccurrences( } cursor = addWeeks(cursor, stepWeeks) } - return out + return out.filter(notSkipped) } /** diff --git a/src/lib/grades.ts b/src/lib/grades.ts index cdc6b74..f2df492 100644 --- a/src/lib/grades.ts +++ b/src/lib/grades.ts @@ -52,6 +52,9 @@ export function formatGrade(value: number | null, system: GradingSystem): string if (value == null) return "–" switch (system) { case "german": + // The German 1,0–4,0 scale is a fixed convention: one decimal, comma + // separator. `de-DE` is intentional and locale-independent here — an + // English UI must still render German grades as "2,0", not "2.0". return value.toLocaleString("de-DE", { minimumFractionDigits: 1, maximumFractionDigits: 1 }) case "points": return value.toLocaleString(undefined, { maximumFractionDigits: 1 }) diff --git a/src/lib/jobs/index.ts b/src/lib/jobs/index.ts index f98b189..e89a8af 100644 --- a/src/lib/jobs/index.ts +++ b/src/lib/jobs/index.ts @@ -15,6 +15,7 @@ export const QUEUE_FINALIZE_UPLOAD = "finalize-upload" export const QUEUE_SEND_REMINDERS = "send-reminders" export const QUEUE_DAILY_PLAN = "daily-plan-reminder" export const QUEUE_CHECK_UPDATES = "check-updates" +export const QUEUE_SWEEP_ORPHAN_FILES = "sweep-orphan-files" const ALL_QUEUES = [ QUEUE_EMBED_MATERIAL, @@ -28,8 +29,13 @@ const ALL_QUEUES = [ QUEUE_SEND_REMINDERS, QUEUE_DAILY_PLAN, QUEUE_CHECK_UPDATES, + QUEUE_SWEEP_ORPHAN_FILES, ] +/** Payload for the orphan-file sweep: storage paths whose material rows were + * (or are about to be) removed by a cascade delete. */ +export type SweepOrphanFilesPayload = { paths: string[] } + /** Whether this process should run job handlers in-process (default true). Set * WORKERS_IN_PROCESS=false on the web tier when a dedicated worker runs. */ function workersInProcess(): boolean { @@ -45,10 +51,15 @@ export async function startClient(): Promise { boss.on("error", (error: Error) => console.error("[pg-boss]", error)) await boss.start() for (const queue of ALL_QUEUES) await boss.createQueue(queue) - await boss.schedule(QUEUE_SEND_REMINDERS, "*/5 * * * *") - await boss.schedule(QUEUE_POLL_BATCHES, "*/5 * * * *") - await boss.schedule(QUEUE_DAILY_PLAN, "0 7 * * *") - await boss.schedule(QUEUE_CHECK_UPDATES, "0 6 * * *") + // pg-boss evaluates cron in the schedule's timezone. It defaults to UTC, which + // makes "0 7 * * *" fire at 07:00 UTC — often the wrong local morning for a + // self-hosted single-tenant deploy. `CRON_TZ` (IANA name, e.g. + // "Europe/Berlin") overrides it; UTC stays the default. + const tz = process.env.CRON_TZ || "UTC" + await boss.schedule(QUEUE_SEND_REMINDERS, "*/5 * * * *", {}, { tz }) + await boss.schedule(QUEUE_POLL_BATCHES, "*/5 * * * *", {}, { tz }) + await boss.schedule(QUEUE_DAILY_PLAN, "0 7 * * *", {}, { tz }) + await boss.schedule(QUEUE_CHECK_UPDATES, "0 6 * * *", {}, { tz }) return boss } @@ -131,6 +142,21 @@ export async function registerWorkers(boss: PgBoss): Promise { console.error("[check-updates]", error) } }) + + await boss.work(QUEUE_SWEEP_ORPHAN_FILES, async (jobs) => { + const { deleteFile } = await import("@/lib/storage") + for (const job of jobs) { + for (const path of job.data.paths) { + // Best-effort: a path that's already gone (double-run, prior partial + // sweep) is a no-op — never fail the whole batch over one missing file. + try { + await deleteFile(path) + } catch (error) { + console.warn("[sweep-orphan-files] delete failed", path, error) + } + } + } + }) } async function start(): Promise { @@ -239,3 +265,23 @@ export async function enqueueFinalizeUpload( singletonKey: payload.tusId, }) } + +/** + * Enqueues deletion of storage blobs orphaned by a cascade delete (a program / + * semester / module and all its materials). Deleting the underlying files is + * deferred to this job so the delete action returns fast and a crash mid-sweep + * just retries (each unlink is idempotent). Chunked so a huge subtree doesn't + * produce one oversized job payload. + */ +export async function enqueueSweepOrphanFiles(paths: string[]): Promise { + if (paths.length === 0) return + const boss = await getBoss() + const CHUNK = 500 + for (let i = 0; i < paths.length; i += CHUNK) { + await boss.send( + QUEUE_SWEEP_ORPHAN_FILES, + { paths: paths.slice(i, i + CHUNK) }, + { retryLimit: 5, retryDelay: 60 } + ) + } +} diff --git a/src/lib/jobs/reminders.ts b/src/lib/jobs/reminders.ts index 17f1030..f3e33de 100644 --- a/src/lib/jobs/reminders.ts +++ b/src/lib/jobs/reminders.ts @@ -1,6 +1,8 @@ import "server-only" import { and, eq, gt, gte, isNotNull, isNull, lte, ne, or } from "drizzle-orm" +import { createTranslator } from "next-intl" import { db } from "@/db" +import { routing } from "@/i18n/routing" import { assignment, notificationPrefs, @@ -10,6 +12,7 @@ import { semesterPlan, studyEvent, user, + userPrefs, type NotificationChannels, } from "@/db/schema" import { sendEmail } from "@/lib/email" @@ -18,6 +21,37 @@ import { sendPushToUser } from "@/lib/push" import { getAppName } from "@/lib/settings" import { env } from "@/lib/env" +/** + * Resolves the notification locale for a user from `user_prefs.locale` + * (migration 0042), falling back to the app default when unset or unsupported. + * All reminder formatting flows through the returned locale. + */ +async function reminderLocale(userId: string): Promise { + const prefs = await db.query.userPrefs.findFirst({ + where: eq(userPrefs.userId, userId), + columns: { locale: true }, + }) + const locale = prefs?.locale + return locale && routing.locales.includes(locale as (typeof routing.locales)[number]) + ? locale + : routing.defaultLocale +} + +type NotificationsT = (key: string, values?: Record) => string + +const messagesCache = new Map>() + +/** Server-side (request-free, job-safe) translator for the `notifications` namespace. */ +async function notificationsTranslator(locale: string): Promise { + let messages = messagesCache.get(locale) + if (!messages) { + messages = (await import(`../../../messages/${locale}.json`)).default + messagesCache.set(locale, messages!) + } + const t = createTranslator({ locale, messages, namespace: "notifications" }) + return t as unknown as NotificationsT +} + /** Resolves the category × channel matrix, falling back to the legacy booleans. */ async function getChannels(userId: string): Promise { const prefs = await db.query.notificationPrefs.findFirst({ @@ -106,13 +140,17 @@ export async function sendDueReminders(): Promise { if (inserted.length === 0) continue const channels = await getChannels(event.userId) - const when = occ.startsAt.toLocaleString("de-DE", { + const locale = await reminderLocale(event.userId) + const t = await notificationsTranslator(locale) + const when = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: event.allDay ? undefined : "short", - }) + }).format(occ.startsAt) await notify(event.userId, channels.events, { - title: `⏰ ${event.title}`, - body: `${when}${event.location ? ` · ${event.location}` : ""}`, + title: t("eventTitle", { title: event.title }), + body: event.location + ? t("eventBodyLocation", { when, location: event.location }) + : t("eventBody", { when }), url: "/calendar", }) } @@ -145,9 +183,14 @@ async function sendAssignmentReminders(now: Date): Promise { if (!(await claimNotification(row.userId, `assignment:${row.id}:1440`))) continue const channels = await getChannels(row.userId) + const locale = await reminderLocale(row.userId) + const t = await notificationsTranslator(locale) + const dueLabel = new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format( + new Date(row.dueDate!) + ) await notify(row.userId, channels.assignments, { - title: `📋 ${row.title}`, - body: `${row.module.name} · ${new Date(row.dueDate!).toLocaleDateString("de-DE", { dateStyle: "medium" })}`, + title: t("assignmentTitle", { title: row.title }), + body: t("assignmentBody", { module: row.module.name, due: dueLabel }), url: "/calendar", }) } @@ -155,7 +198,8 @@ async function sendAssignmentReminders(now: Date): Promise { /** Daily morning reminder listing today's open plan sessions. */ export async function sendDailyPlanReminders(): Promise { - const today = new Date().toISOString().slice(0, 10) + // Local ISO date, matching how plan_session.date is written (B6/day-key policy). + const today = toIsoDate(new Date()) const sessions = await db .select({ userId: semesterPlan.userId }) @@ -172,9 +216,11 @@ export async function sendDailyPlanReminders(): Promise { for (const [userId, count] of byUser) { if (!(await claimNotification(userId, `dailyplan:${today}`))) continue const channels = await getChannels(userId) + const locale = await reminderLocale(userId) + const t = await notificationsTranslator(locale) await notify(userId, channels.dailyPlan, { - title: `📚 ${appName}`, - body: `Heute stehen ${count} Lerneinheiten in deinem Lernplan.`, + title: t("dailyPlanTitle", { appName }), + body: t("dailyPlanBody", { count }), url: "/", }) } diff --git a/src/lib/jobs/unpack-zip.ts b/src/lib/jobs/unpack-zip.ts index 45b7ee6..fd73e9b 100644 --- a/src/lib/jobs/unpack-zip.ts +++ b/src/lib/jobs/unpack-zip.ts @@ -90,6 +90,27 @@ export async function unpackZip(payload: UnpackZipPayload): Promise { console.error("[unpack-zip] failed to enqueue embedding", error) } } + } catch (error) { + // The archive isn't kept as its own material, so a failed unpack would + // otherwise vanish silently (retryLimit 0). Record a failed material row + // for the archive so the materials list badge + detail banner (E1) surface + // the reason to the user. + const message = error instanceof Error ? error.message : String(error) + console.error(`[unpack-zip] failed to unpack ${zipName}`, error) + try { + await db.insert(material).values({ + userId, + moduleId, + kind: "file", + name: zipName, + mimeType: "application/zip", + folderId: parentFolderId, + extractionStatus: "failed", + extractionError: `Could not unpack archive: ${message}`, + }) + } catch (insertError) { + console.error("[unpack-zip] failed to record unpack error", insertError) + } } finally { // Discard the temporary archive regardless of outcome. await deleteFile(zipStoragePath) diff --git a/src/lib/learning/fsrs.test.ts b/src/lib/learning/fsrs.test.ts index 77ff15c..c173632 100644 --- a/src/lib/learning/fsrs.test.ts +++ b/src/lib/learning/fsrs.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest" -import { scheduleReview, type CardSchedulingFields } from "./fsrs" +import { + formatReviewInterval, + previewIntervals, + scheduleReview, + type CardSchedulingFields, +} from "./fsrs" const newCard = (): CardSchedulingFields => ({ due: new Date("2026-07-01T10:00:00Z"), @@ -38,3 +43,29 @@ describe("scheduleReview", () => { expect(second.reps).toBe(2) }) }) + +describe("previewIntervals", () => { + it("returns a monotonic due date per rating without mutating the card", () => { + const now = new Date("2026-07-01T10:00:00Z") + const p = previewIntervals(newCard(), now) + // Again ≤ Hard ≤ Good ≤ Easy in next-due distance. + expect(p[1].getTime()).toBeLessThanOrEqual(p[2].getTime()) + expect(p[2].getTime()).toBeLessThanOrEqual(p[3].getTime()) + expect(p[3].getTime()).toBeLessThanOrEqual(p[4].getTime()) + // Matches the real schedule for a chosen rating. + expect(p[3].getTime()).toBe(scheduleReview(newCard(), 3, now).due.getTime()) + }) +}) + +describe("formatReviewInterval", () => { + const from = new Date("2026-07-01T10:00:00Z") + it("formats minutes, hours, days and months", () => { + expect(formatReviewInterval(from, new Date("2026-07-01T10:10:00Z"))).toBe("10m") + expect(formatReviewInterval(from, new Date("2026-07-01T13:00:00Z"))).toBe("3h") + expect(formatReviewInterval(from, new Date("2026-07-05T10:00:00Z"))).toBe("4d") + expect(formatReviewInterval(from, new Date("2026-09-01T10:00:00Z"))).toBe("2mo") + }) + it("clamps sub-minute gaps", () => { + expect(formatReviewInterval(from, new Date("2026-07-01T10:00:20Z"))).toBe("<1m") + }) +}) diff --git a/src/lib/learning/fsrs.ts b/src/lib/learning/fsrs.ts index 4e0c8b6..2fd5e93 100644 --- a/src/lib/learning/fsrs.ts +++ b/src/lib/learning/fsrs.ts @@ -54,3 +54,37 @@ export function scheduleReview( lastReview: card.last_review ?? null, } } + +/** + * Next-due date for each of the four ratings, without mutating the card — + * powers the interval preview on the rating buttons (E8). Pure: no DB, no + * server round-trip. + */ +export function previewIntervals( + fields: CardSchedulingFields, + now: Date = new Date() +): Record { + const card = toFsrsCard(fields) + const out = {} as Record + for (const rating of [1, 2, 3, 4] as const) { + out[rating] = scheduler.next(card, now, rating as Grade).card.due + } + return out +} + +/** + * Compact human label for the gap between `from` and `due` ("<1m", "10m", + * "3h", "4d", "2mo"). Rounds to the coarsest natural unit so the preview stays + * glanceable. Pure — see fsrs.test.ts. + */ +export function formatReviewInterval(from: Date, due: Date): string { + const mins = Math.round((due.getTime() - from.getTime()) / 60000) + if (mins < 1) return "<1m" + if (mins < 60) return `${mins}m` + const hours = Math.round(mins / 60) + if (hours < 24) return `${hours}h` + const days = Math.round(hours / 24) + if (days < 30) return `${days}d` + const months = Math.round(days / 30) + return `${months}mo` +} diff --git a/src/lib/plan/absences.ts b/src/lib/plan/absences.ts index 6c6ebe4..b617a24 100644 --- a/src/lib/plan/absences.ts +++ b/src/lib/plan/absences.ts @@ -34,6 +34,11 @@ export function expandAbsences( ): AbsenceWindow[] { const windows: AbsenceWindow[] = [] + // Defensive bound: never iterate more than ~400 days internally even if the + // caller passes a far-future `to` (the day-loops below are O(days)). + const maxTo = new Date(from.getTime() + 400 * 86400000) + if (to.getTime() > maxTo.getTime()) to = maxTo + // One-off date ranges (vacations) — all-day per day for (const b of availability.blackouts ?? []) { const start = new Date(b.from) diff --git a/src/lib/plan/readiness.test.ts b/src/lib/plan/readiness.test.ts new file mode 100644 index 0000000..0a6b7eb --- /dev/null +++ b/src/lib/plan/readiness.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest" +import { capacityMinutes, computeReadiness, windowMinutes } from "./readiness" + +describe("computeReadiness", () => { + it("is on_track when nothing remains", () => { + expect(computeReadiness({ remainingMinutes: 0, capacityMinutes: 0 })).toBe("on_track") + expect(computeReadiness({ remainingMinutes: -10, capacityMinutes: 100 })).toBe("on_track") + }) + + it("is on_track at or below 0.8× capacity", () => { + expect(computeReadiness({ remainingMinutes: 80, capacityMinutes: 100 })).toBe("on_track") + expect(computeReadiness({ remainingMinutes: 50, capacityMinutes: 100 })).toBe("on_track") + }) + + it("is at_risk between 0.8× and 1× capacity", () => { + expect(computeReadiness({ remainingMinutes: 90, capacityMinutes: 100 })).toBe("at_risk") + expect(computeReadiness({ remainingMinutes: 100, capacityMinutes: 100 })).toBe("at_risk") + }) + + it("is unreachable above capacity", () => { + expect(computeReadiness({ remainingMinutes: 101, capacityMinutes: 100 })).toBe("unreachable") + }) + + it("is unreachable when there is work but no capacity", () => { + expect(computeReadiness({ remainingMinutes: 10, capacityMinutes: 0 })).toBe("unreachable") + }) +}) + +describe("windowMinutes", () => { + it("computes duration and never goes negative", () => { + expect(windowMinutes("18:00", "20:00")).toBe(120) + expect(windowMinutes("09:30", "10:00")).toBe(30) + expect(windowMinutes("20:00", "18:00")).toBe(0) + }) +}) + +describe("capacityMinutes", () => { + it("returns 0 when the exam is not in the future", () => { + expect( + capacityMinutes({ + today: "2026-07-19", + examDate: "2026-07-19", + weekly: [{ weekday: 0, from: "09:00", to: "17:00" }], + blocked: [], + }) + ).toBe(0) + }) + + it("sums weekday windows up to (excluding) the exam day", () => { + // 2026-07-19 is a Sunday. Window on every day 18:00–20:00 (120 min). + // Days counted: Sun 19 .. Wed 22 (exam on Thu 23) → 4 days × 120 = 480. + const weekly = [0, 1, 2, 3, 4, 5, 6].map((weekday) => ({ + weekday, + from: "18:00", + to: "20:00", + })) + expect( + capacityMinutes({ today: "2026-07-19", examDate: "2026-07-23", weekly, blocked: [] }) + ).toBe(480) + }) + + it("subtracts all-day and timed blocks", () => { + const weekly = [0, 1, 2, 3, 4, 5, 6].map((weekday) => ({ + weekday, + from: "18:00", + to: "20:00", + })) + // 4 days × 120 = 480, minus an all-day off (−120) and a 30-min timed block. + const blocked = [ + { date: "2026-07-20", from: null, to: null }, + { date: "2026-07-21", from: "18:00", to: "18:30" }, + ] + expect( + capacityMinutes({ today: "2026-07-19", examDate: "2026-07-23", weekly, blocked }) + ).toBe(480 - 120 - 30) + }) +}) diff --git a/src/lib/plan/readiness.ts b/src/lib/plan/readiness.ts new file mode 100644 index 0000000..81a8764 --- /dev/null +++ b/src/lib/plan/readiness.ts @@ -0,0 +1,87 @@ +/** + * Exam-goal readiness (pure, testable). + * + * Compares the study time a goal still needs (`remainingMinutes`, from its + * open tasks) against the study capacity available before the exam + * (`capacityMinutes`, from the weekly windows minus absences). Returns a + * traffic-light state used by the goal card. + */ + +export type Readiness = "on_track" | "at_risk" | "unreachable" + +/** + * Traffic light for an exam goal: + * - `on_track` remaining ≤ 0.8 × capacity (or nothing left to do) + * - `at_risk` remaining ≤ capacity + * - `unreachable` remaining > capacity (or no capacity at all) + */ +export function computeReadiness({ + remainingMinutes, + capacityMinutes, +}: { + remainingMinutes: number + capacityMinutes: number +}): Readiness { + if (remainingMinutes <= 0) return "on_track" + if (capacityMinutes <= 0) return "unreachable" + if (remainingMinutes <= 0.8 * capacityMinutes) return "on_track" + if (remainingMinutes <= capacityMinutes) return "at_risk" + return "unreachable" +} + +/** Minutes between two "HH:mm" times (never negative). */ +export function windowMinutes(from: string, to: string): number { + const [fh, fm] = from.split(":").map(Number) + const [th, tm] = to.split(":").map(Number) + return Math.max(0, th * 60 + tm - (fh * 60 + fm)) +} + +const MS_PER_DAY = 86_400_000 +const parseDay = (date: string) => Date.parse(`${date}T00:00:00Z`) + +/** + * Sums the available study minutes from `today` (inclusive) up to but NOT + * including `examDate` (no session lands on the exam day). For each day it adds + * that weekday's window minutes and subtracts blocked windows: an all-day block + * (`from == null`) zeroes the day, a timed block subtracts its own length + * (clamped, approximate — overlaps are not intersected). Capped at 400 days. + */ +export function capacityMinutes({ + today, + examDate, + weekly, + blocked, +}: { + today: string + examDate: string + weekly: { weekday: number; from: string; to: string }[] + blocked: { date: string; from: string | null; to: string | null }[] +}): number { + if (examDate <= today) return 0 + + const byWeekday = new Map() + for (const w of weekly) { + byWeekday.set(w.weekday, (byWeekday.get(w.weekday) ?? 0) + windowMinutes(w.from, w.to)) + } + + const allDayOff = new Set() + const timedOff = new Map() + for (const b of blocked) { + if (b.from == null || b.to == null) allDayOff.add(b.date) + else timedOff.set(b.date, (timedOff.get(b.date) ?? 0) + windowMinutes(b.from, b.to)) + } + + let total = 0 + const start = parseDay(today) + const end = parseDay(examDate) + const maxDays = 400 + for (let i = 0, ms = start; ms < end && i < maxDays; i++, ms += MS_PER_DAY) { + const date = new Date(ms).toISOString().slice(0, 10) + if (allDayOff.has(date)) continue + const weekday = new Date(ms).getUTCDay() + const dayMinutes = byWeekday.get(weekday) ?? 0 + if (dayMinutes === 0) continue + total += Math.max(0, dayMinutes - (timedOff.get(date) ?? 0)) + } + return total +} diff --git a/src/lib/plan/replan.test.ts b/src/lib/plan/replan.test.ts new file mode 100644 index 0000000..8683e68 --- /dev/null +++ b/src/lib/plan/replan.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest" +import { collectReplannableTasks, type ReplanSession, type ReplanTask } from "./replan" + +const TODAY = "2026-08-10" + +describe("collectReplannableTasks", () => { + it("excludes tasks on FUTURE pinned or done sessions", () => { + const sessions: ReplanSession[] = [ + { id: "s-pinned", date: "2026-08-15", pinned: true, done: false }, + { id: "s-done", date: "2026-08-16", pinned: false, done: true }, + { id: "s-open", date: "2026-08-17", pinned: false, done: false }, + ] + const tasks: ReplanTask[] = [ + { id: "t-pinned", sessionId: "s-pinned" }, + { id: "t-done", sessionId: "s-done" }, + { id: "t-open", sessionId: "s-open" }, + { id: "t-free", sessionId: null }, + ] + const ids = collectReplannableTasks(tasks, sessions, TODAY) + expect(ids).toContain("t-open") + expect(ids).toContain("t-free") + expect(ids).not.toContain("t-pinned") + expect(ids).not.toContain("t-done") + }) + + it("INCLUDES tasks on PAST sessions even if pinned/done (catch-up)", () => { + const sessions: ReplanSession[] = [ + { id: "past-done", date: "2026-08-05", pinned: false, done: true }, + { id: "past-pinned", date: "2026-08-06", pinned: true, done: false }, + { id: "past-undone", date: "2026-08-07", pinned: false, done: false }, + ] + const tasks: ReplanTask[] = [ + { id: "t1", sessionId: "past-done" }, + { id: "t2", sessionId: "past-pinned" }, + { id: "t3", sessionId: "past-undone" }, + ] + const ids = collectReplannableTasks(tasks, sessions, TODAY) + expect(ids.sort()).toEqual(["t1", "t2", "t3"]) + }) + + it("treats a session dated exactly today as future", () => { + const sessions: ReplanSession[] = [{ id: "s", date: TODAY, pinned: true, done: false }] + const tasks: ReplanTask[] = [{ id: "t", sessionId: "s" }] + expect(collectReplannableTasks(tasks, sessions, TODAY)).toEqual([]) + }) + + it("includes tasks whose session is not among the kept sessions", () => { + const tasks: ReplanTask[] = [{ id: "t", sessionId: "ghost" }] + expect(collectReplannableTasks(tasks, [], TODAY)).toEqual(["t"]) + }) +}) diff --git a/src/lib/plan/replan.ts b/src/lib/plan/replan.ts new file mode 100644 index 0000000..b114650 --- /dev/null +++ b/src/lib/plan/replan.ts @@ -0,0 +1,43 @@ +/** + * Replan selection (pure, DB-free, testable). + * + * Decides which open tasks should be fed back into the scheduler on a recompute. + * A task is kept OUT of the replan only when it sits on a FUTURE session the + * student has effectively frozen (pinned or already done) — moving it would + * fight the student's own choices. Everything else is replannable: + * - tasks with no session yet, + * - tasks on future sessions that are neither pinned nor done, + * - tasks on PAST sessions that were never done (catch-up: they were missed + * and must be rescheduled forward). + */ + +export type ReplanTask = { id: string; sessionId: string | null } + +export type ReplanSession = { + id: string + /** ISO date (YYYY-MM-DD). */ + date: string + pinned: boolean + done: boolean +} + +/** + * Returns the ids of the tasks that should be re-planned. + * + * @param tasks open tasks (done=false) with their current sessionId + * @param keptSessions the sessions being preserved (typically today-forward) + * @param today ISO date used to classify future vs. past sessions + */ +export function collectReplannableTasks( + tasks: ReplanTask[], + keptSessions: ReplanSession[], + today: string +): string[] { + // Sessions that are frozen and in the future → their tasks are left alone. + const frozenFuture = new Set( + keptSessions.filter((s) => s.date >= today && (s.pinned || s.done)).map((s) => s.id) + ) + return tasks + .filter((t) => t.sessionId == null || !frozenFuture.has(t.sessionId)) + .map((t) => t.id) +} diff --git a/src/lib/plan/scheduler.test.ts b/src/lib/plan/scheduler.test.ts index c6462a3..e527e64 100644 --- a/src/lib/plan/scheduler.test.ts +++ b/src/lib/plan/scheduler.test.ts @@ -335,3 +335,220 @@ describe("DEFAULT_SCHEDULE_CONFIG", () => { }) }) }) + +// ---- consolidation window ------------------------------------------------------ + +/** Categorized task helper for consolidation tests. */ +function ctask( + id: string, + category: "learn" | "review" | "cards", + minutes = 60, + dueDate: string | null = null +) { + return { id, estimatedMinutes: minutes, dueDate, category } +} + +/** ISO-week key mirroring the scheduler's internal grouping. */ +function weekKeyOf(date: string): string { + const d = new Date(Date.parse(`${date}T00:00:00Z`)) + const day = (d.getUTCDay() + 6) % 7 + d.setUTCDate(d.getUTCDate() - day + 3) + const firstThursday = Date.UTC(d.getUTCFullYear(), 0, 4) + const ft = new Date(firstThursday) + const ftDay = (ft.getUTCDay() + 6) % 7 + ft.setUTCDate(ft.getUTCDate() - ftDay + 3) + const week = 1 + Math.round((d.getTime() - ft.getTime()) / (7 * 86400000)) + return `${d.getUTCFullYear()}-W${String(week).padStart(2, "0")}` +} + +describe("computeSchedule — consolidation window", () => { + const START = "2026-08-17" + const END = "2026-08-24" // exam day + const consolidation = { start: START, end: END } + + it("(a) never places a learn task on/after the window start", () => { + const input = baseInput({ + today: "2026-08-03", + horizonEnd: "2026-08-31", + availabilityWindows: allDays("09:00", "12:00"), + modules: [ + mod({ + moduleId: "m", + consolidation, + tasks: [0, 1, 2, 3].map((i) => ctask(`learn${i}`, "learn", 60, START)), + }), + ], + }) + const { sessions } = computeSchedule(input) + expect(sessions.length).toBeGreaterThan(0) + for (const s of sessions) expect(s.date < START).toBe(true) + }) + + it("(b) review/cards tasks land inside the window", () => { + const input = baseInput({ + today: START, + horizonEnd: "2026-08-31", + availabilityWindows: allDays("09:00", "12:00"), + modules: [ + mod({ + moduleId: "m", + consolidation, + tasks: [ + ctask("r0", "review", 60, "2026-08-20"), + ctask("c0", "cards", 30, "2026-08-20"), + ctask("r1", "review", 60, "2026-08-22"), + ], + }), + ], + }) + const { sessions } = computeSchedule(input) + expect(sessions.length).toBeGreaterThan(0) + for (const s of sessions) { + expect(s.date >= START && s.date <= END).toBe(true) + expect(s.date).not.toBe(END) + } + }) + + it("(c) an assignment learn task due INSIDE the window is still scheduled", () => { + const input = baseInput({ + today: START, + horizonEnd: "2026-08-31", + availabilityWindows: allDays("09:00", "12:00"), + modules: [ + mod({ + moduleId: "m", + consolidation, + tasks: [ctask("assign", "learn", 60, "2026-08-20")], + }), + ], + }) + const { sessions, warnings } = computeSchedule(input) + const placed = sessions.find((s) => s.taskIds.includes("assign")) + expect(placed).toBeDefined() + expect(placed!.date <= "2026-08-20").toBe(true) + expect(warnings.some((w) => w.kind === "deadline_unreachable" && w.taskId === "assign")).toBe( + false + ) + }) + + it("(d) never places a session for the module on the exam day", () => { + const input = baseInput({ + today: START, + horizonEnd: "2026-08-31", + availabilityWindows: allDays("09:00", "12:00"), + modules: [ + mod({ + moduleId: "m", + consolidation, + tasks: [ctask("r0", "review", 60, END), ctask("r1", "review", 60, END)], + }), + ], + }) + const { sessions } = computeSchedule(input) + expect(sessions.some((s) => s.moduleId === "m")).toBe(true) + expect(sessions.every((s) => s.date !== END)).toBe(true) + }) + + it("(g) session kind reflects the majority task category", () => { + const reviewHeavy = baseInput({ + today: "2026-08-03", + horizonEnd: "2026-08-03", + availabilityWindows: allDays("09:00", "12:00"), + config: { maxSessionsPerDay: 1, sessionMinutes: { min: 45, max: 180 } }, + modules: [ + mod({ + moduleId: "m", + tasks: [ctask("r0", "review", 30), ctask("r1", "review", 30), ctask("l0", "learn", 30)], + }), + ], + }) + const s1 = computeSchedule(reviewHeavy).sessions + expect(s1).toHaveLength(1) + expect(s1[0].kind).toBe("review") + + const learnHeavy = baseInput({ + today: "2026-08-03", + horizonEnd: "2026-08-03", + availabilityWindows: allDays("09:00", "12:00"), + config: { maxSessionsPerDay: 1, sessionMinutes: { min: 45, max: 180 } }, + modules: [mod({ moduleId: "m", tasks: [ctask("l0", "learn", 30), ctask("l1", "learn", 30)] })], + }) + expect(computeSchedule(learnHeavy).sessions[0].kind).toBe("study") + }) +}) + +// ---- weekly capacity override -------------------------------------------------- + +describe("computeSchedule — weekCapacityOverrides", () => { + it("(e) caps the whole plan's minutes in the overridden ISO week only", () => { + const capWeek = weekKeyOf("2026-08-03") + const build = () => + baseInput({ + today: "2026-08-03", // Monday + horizonEnd: "2026-08-16", // through the following Sunday (two ISO weeks) + availabilityWindows: allDays("09:00", "15:00"), + config: { maxSessionsPerDay: 3, sessionMinutes: { min: 45, max: 60 } }, + weekCapacityOverrides: { [capWeek]: 120 }, // 2h cap in week one + modules: [mod({ moduleId: "m", tasks: tasks(60, 60) })], + }) + const { sessions } = computeSchedule(build()) + const minutesIn = (from: string, to: string) => + sessions + .filter((s) => s.date >= from && s.date <= to) + .reduce((sum, s) => sum + s.durationMinutes, 0) + expect(minutesIn("2026-08-03", "2026-08-09")).toBeLessThanOrEqual(120) + // A later week (no override) is unaffected — more than the cap is placed. + expect(minutesIn("2026-08-10", "2026-08-16")).toBeGreaterThan(120) + // Determinism preserved. + expect(computeSchedule(build())).toEqual(computeSchedule(build())) + }) +}) + +// ---- horizon clipping ---------------------------------------------------------- + +describe("computeSchedule — horizon_clipped", () => { + it("(f) emits horizon_clipped for a task due beyond the horizon", () => { + const input = baseInput({ + today: "2026-08-03", + horizonEnd: "2026-08-16", + availabilityWindows: allDays("09:00", "12:00"), + modules: [ + mod({ + moduleId: "m", + tasks: [{ id: "far", estimatedMinutes: 60, dueDate: "2027-01-01" }], + }), + ], + }) + const { warnings } = computeSchedule(input) + expect(warnings.some((w) => w.kind === "horizon_clipped" && w.taskId === "far")).toBe(true) + }) +}) + +// ---- degenerate config guard (B8) ---------------------------------------------- + +describe("computeSchedule — degenerate config", () => { + const degenerateConfigs = [ + { min: 0, max: 0 }, + { min: 0, max: 180 }, + { min: -10, max: -5 }, + { min: 180, max: 45 }, // max < min + { min: Number.NaN, max: Number.NaN }, + ] + + for (const sessionMinutes of degenerateConfigs) { + it(`terminates and produces valid sessions for ${JSON.stringify(sessionMinutes)}`, () => { + const input = baseInput({ + config: { maxSessionsPerDay: 2, sessionMinutes }, + modules: [mod({ moduleId: "m", tasks: tasks(10, 60) })], + }) + // Must not hang; must still emit only in-availability, sane-length sessions. + const { sessions } = computeSchedule(input) + expect(sessions.length).toBeGreaterThan(0) + for (const s of sessions) { + expect(s.durationMinutes).toBeGreaterThanOrEqual(5) + expect(toMin(s.startTime)).toBeGreaterThanOrEqual(toMin("09:00")) + expect(endMin(s)).toBeLessThanOrEqual(toMin("12:00")) + } + }) + } +}) diff --git a/src/lib/plan/scheduler.ts b/src/lib/plan/scheduler.ts index 656d483..f061c0b 100644 --- a/src/lib/plan/scheduler.ts +++ b/src/lib/plan/scheduler.ts @@ -17,6 +17,12 @@ * moduleId asc). Same input ⇒ byte-identical output. */ +/** What a task (and, in majority, its session) is for. Defaults to "learn". */ +export type TaskCategory = "learn" | "review" | "cards" + +/** What a scheduled session is for — derived from its majority task category. */ +export type SessionKind = "study" | "review" | "cards" + export type ScheduleModuleInput = { moduleId: string weight: number @@ -26,8 +32,15 @@ export type ScheduleModuleInput = { phase: number /** Soft weekday filter (0=Sun..6=Sat). null = any weekday. */ preferredWeekdays: number[] | null - /** Open tasks in priority order. */ - tasks: { id: string; estimatedMinutes: number; dueDate: string | null }[] + /** + * Pre-exam consolidation window (start = examDate−reviewDays, end = examDate). + * When set: no `learn` task lands on/after `start` (unless its own dueDate is + * inside the window), and the module gets no session on `end` (exam day). + * null/absent = legacy behaviour (window rules off). + */ + consolidation?: { start: string; end: string } | null + /** Open tasks in priority order. `category` defaults to "learn". */ + tasks: { id: string; estimatedMinutes: number; dueDate: string | null; category?: TaskCategory }[] } export type ScheduleInput = { @@ -42,6 +55,11 @@ export type ScheduleInput = { config: { maxSessionsPerDay: number; sessionMinutes: { min: number; max: number } } /** Fixed blocks the scheduler plans around (kept as-is, not re-emitted). */ pinnedSessions: { date: string; startTime: string; durationMinutes: number }[] + /** + * Optional per-ISO-week cap (weekKey → minutes) on the WHOLE plan's assigned + * minutes that week ("this week I only have 4h"). Absent key = no cap. + */ + weekCapacityOverrides?: Record modules: ScheduleModuleInput[] } @@ -51,10 +69,12 @@ export type ScheduledSession = { startTime: string durationMinutes: number taskIds: string[] + /** Majority task category of the session (study | review | cards). */ + kind: SessionKind } export type ScheduleWarning = { - kind: "deadline_unreachable" | "no_capacity" + kind: "deadline_unreachable" | "no_capacity" | "horizon_clipped" moduleId?: string taskId?: string message?: string @@ -140,21 +160,66 @@ function subtract(base: Interval[], blockers: Interval[]): Interval[] { // ---- module scheduling state --------------------------------------------------- +type QueueTask = { + id: string + estimatedMinutes: number + dueDate: string | null + category: TaskCategory +} + type ModuleState = { input: ScheduleModuleInput - queue: { id: string; estimatedMinutes: number; dueDate: string | null }[] + queue: QueueTask[] assignedMinutes: number weekMinutes: Map } -function earliestDue(state: ModuleState): string | null { +/** + * True if `task` may be placed on `date` for this module. Legacy (no + * consolidation) → always true. With a window: no session at all on the exam + * day; `learn` tasks not on/after `start` unless their own dueDate is inside + * the window; `review`/`cards` unrestricted. + */ +function isPlaceable(state: ModuleState, task: QueueTask, date: string): boolean { + const c = state.input.consolidation + if (!c) return true + if (date === c.end) return false // rule 3: no session on exam day + if (task.category !== "learn") return true // review/cards placed normally + if (date < c.start) return true + // On/after the window start a learn task is only allowed if it is itself due + // inside the window (e.g. an assignment due during the run-up). + return task.dueDate != null && task.dueDate >= c.start && task.dueDate <= c.end +} + +function hasPlaceableWork(state: ModuleState, date: string): boolean { + return state.queue.some((t) => isPlaceable(state, t, date)) +} + +/** Earliest dueDate among the tasks placeable on `date` (null if none). */ +function earliestPlaceableDue(state: ModuleState, date: string): string | null { let best: string | null = null for (const t of state.queue) { + if (!isPlaceable(state, t, date)) continue if (t.dueDate && (best === null || t.dueDate < best)) best = t.dueDate } return best } +/** Session kind = majority task category (ties favour study, then review). */ +function majorityKind(categories: TaskCategory[]): SessionKind { + let learn = 0 + let review = 0 + let cards = 0 + for (const c of categories) { + if (c === "review") review++ + else if (c === "cards") cards++ + else learn++ + } + if (learn >= review && learn >= cards) return "study" + if (review >= cards) return "review" + return "cards" +} + /** Deterministic ordering: phase asc, weight desc, moduleId asc. */ function tieBreak(a: ModuleState, b: ModuleState): number { if (a.input.phase !== b.input.phase) return a.input.phase - b.input.phase @@ -166,17 +231,32 @@ function tieBreak(a: ModuleState, b: ModuleState): number { export function computeSchedule(input: ScheduleInput): ScheduleResult { const { config } = input - const { min, max } = config.sessionMinutes + // Degenerate-config guard: a min<=0 or max=5, max>=min), falling back to the defaults when unusable. + const rawMin = Number(config.sessionMinutes?.min) + const rawMax = Number(config.sessionMinutes?.max) + const min = + Number.isFinite(rawMin) && rawMin >= 5 + ? Math.floor(rawMin) + : DEFAULT_SCHEDULE_CONFIG.sessionMinutes.min + const max = + Number.isFinite(rawMax) && rawMax >= min + ? Math.floor(rawMax) + : Math.max(min, DEFAULT_SCHEDULE_CONFIG.sessionMinutes.max) const sessions: ScheduledSession[] = [] const warnings: ScheduleWarning[] = [] const states: ModuleState[] = input.modules.map((m) => ({ input: m, - queue: m.tasks.map((t) => ({ ...t })), + queue: m.tasks.map((t) => ({ ...t, category: t.category ?? "learn" })), assignedMinutes: 0, weekMinutes: new Map(), })) + // Plan-wide assigned minutes per ISO week (for weekCapacityOverrides). + const planWeekMinutes = new Map() + // Group inputs by day for O(1) lookup during the walk. const availByWeekday = new Map() for (const w of input.availabilityWindows) { @@ -231,7 +311,10 @@ export function computeSchedule(input: ScheduleInput): ScheduleResult { const slots: Interval[] = [] for (const iv of free) { let cursor = iv.start - while (iv.end - cursor >= min) { + let slotGuard = 0 + // Backstop against a non-terminating cut (defensive; min>=5 above already + // guarantees the cursor advances). + while (iv.end - cursor >= min && slotGuard++ < 10000) { let size = Math.min(max, iv.end - cursor) const leftover = iv.end - cursor - size // Absorb a sub-`min` sliver into this slot rather than orphaning it. @@ -243,26 +326,41 @@ export function computeSchedule(input: ScheduleInput): ScheduleResult { const budget = Math.max(0, config.maxSessionsPerDay - (pinnedCountByDate.get(date) ?? 0)) const weekKey = isoWeekKey(date) + const weekOverride = input.weekCapacityOverrides?.[weekKey] for (const slot of slots.slice(0, budget)) { + // Plan-wide weekly cap ("this week only Xh"): stop placing once reached. + if (weekOverride != null && weekOverride - (planWeekMinutes.get(weekKey) ?? 0) < min) break + const chosen = selectModule(states, date, weekday, weekKey, min) if (!chosen) continue const slotCap = slot.end - slot.start - const cap = capacityFor(chosen, weekKey, slotCap, min) + let cap = capacityFor(chosen, weekKey, slotCap, min) + if (weekOverride != null) { + cap = Math.min(cap, weekOverride - (planWeekMinutes.get(weekKey) ?? 0)) + } if (cap < min) continue - // Pack tasks until the capacity is used (always at least one task). + // Pack placeable tasks until the capacity is used (always at least one). const taskIds: string[] = [] + const categories: TaskCategory[] = [] let used = 0 - while (chosen.queue.length > 0) { - const t = chosen.queue[0] + let i = 0 + while (i < chosen.queue.length) { + const t = chosen.queue[i] + if (!isPlaceable(chosen, t, date)) { + i++ + continue + } if (taskIds.length > 0 && used + t.estimatedMinutes > cap) break taskIds.push(t.id) + categories.push(t.category) used += t.estimatedMinutes placedOn.set(t.id, date) - chosen.queue.shift() + chosen.queue.splice(i, 1) // remove placed task; do not advance i } + if (taskIds.length === 0) continue const durationMinutes = Math.max(min, Math.min(used, cap)) sessions.push({ @@ -271,13 +369,29 @@ export function computeSchedule(input: ScheduleInput): ScheduleResult { startTime: toHHmm(slot.start), durationMinutes, taskIds, + kind: majorityKind(categories), }) chosen.assignedMinutes += durationMinutes chosen.weekMinutes.set(weekKey, (chosen.weekMinutes.get(weekKey) ?? 0) + durationMinutes) + planWeekMinutes.set(weekKey, (planWeekMinutes.get(weekKey) ?? 0) + durationMinutes) } } // ---- warnings ---- + // A task due beyond the (already-clipped) horizon can never be reached here; + // surface it so the caller can flag the far-future deadline. + for (const s of states) { + for (const t of s.input.tasks) { + if (t.dueDate && t.dueDate > input.horizonEnd) { + warnings.push({ + kind: "horizon_clipped", + moduleId: s.input.moduleId, + taskId: t.id, + message: `Task ${t.id} is due ${t.dueDate}, beyond the planning horizon ${input.horizonEnd}.`, + }) + } + } + } for (const s of states) { const unplacedDeadline = s.queue.filter((t) => t.dueDate) for (const t of unplacedDeadline) { @@ -341,17 +455,24 @@ function selectModule( weekKey: string, min: number ): ModuleState | null { - const withWork = states.filter((s) => s.queue.length > 0 && hasWeekCapacity(s, weekKey, min)) + const withWork = states.filter( + (s) => s.queue.length > 0 && hasWeekCapacity(s, weekKey, min) && hasPlaceableWork(s, date) + ) if (withWork.length === 0) return null const activePhase = Math.min(...withWork.map((s) => s.input.phase)) + // B10: precompute each module's earliest (placeable) due once per pass rather + // than recomputing it inside the sort comparator. + const dueOf = new Map() + for (const s of withWork) dueOf.set(s, earliestPlaceableDue(s, date)) + // Deadline-driven modules cross phase and weekday boundaries. - const deadline = withWork.filter((s) => earliestDue(s) !== null) + const deadline = withWork.filter((s) => dueOf.get(s) != null) if (deadline.length > 0) { return [...deadline].sort((a, b) => { - const da = earliestDue(a)! - const db = earliestDue(b)! + const da = dueOf.get(a)! + const db = dueOf.get(b)! if (da !== db) return da < db ? -1 : 1 return tieBreak(a, b) })[0] diff --git a/src/lib/plan/staleness.ts b/src/lib/plan/staleness.ts new file mode 100644 index 0000000..2425213 --- /dev/null +++ b/src/lib/plan/staleness.ts @@ -0,0 +1,17 @@ +import "server-only" +import { eq } from "drizzle-orm" +import { db } from "@/db" +import { semesterPlan } from "@/db/schema" + +/** + * Marks a semester's plan as stale (out of date). A no-op if the semester has + * no plan row yet. Callers that mutate plan-relevant data (tasks, prefs, + * availability, exam dates) call this so the UI can offer a "replan" banner + * without forcing an immediate, possibly-expensive recompute. + */ +export async function markPlanStale(semesterId: string): Promise { + await db + .update(semesterPlan) + .set({ staleAt: new Date() }) + .where(eq(semesterPlan.semesterId, semesterId)) +} diff --git a/src/lib/plan/task-validation.test.ts b/src/lib/plan/task-validation.test.ts new file mode 100644 index 0000000..554fef1 --- /dev/null +++ b/src/lib/plan/task-validation.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest" +import { dueDateField, maxDueIso } from "./task-validation" + +describe("dueDateField — due date clamp (B1)", () => { + it("accepts a near-future date", () => { + const soon = new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10) + expect(dueDateField.safeParse(soon).success).toBe(true) + }) + + it("rejects a far-future date beyond ~3 years", () => { + expect(dueDateField.safeParse("9999-12-31").success).toBe(false) + }) + + it("accepts a date just under the 3-year cap and rejects one just over", () => { + const now = new Date() + const cap = maxDueIso(now) + const overCap = new Date(now.getTime() + (3 * 365 + 5) * 86400000).toISOString().slice(0, 10) + expect(dueDateField.safeParse(cap).success).toBe(true) + expect(dueDateField.safeParse(overCap).success).toBe(false) + }) + + it("still rejects malformed date strings", () => { + expect(dueDateField.safeParse("not-a-date").success).toBe(false) + }) +}) diff --git a/src/lib/plan/task-validation.ts b/src/lib/plan/task-validation.ts new file mode 100644 index 0000000..80c927b --- /dev/null +++ b/src/lib/plan/task-validation.ts @@ -0,0 +1,16 @@ +import { z } from "zod" + +/** + * Upper bound for a plan-task due date: today + 3 years (ISO YYYY-MM-DD). + * Prevents a client from storing e.g. "9999-12-31", which would otherwise blow + * up the scheduling horizon (see B1). Computed per-call so it tracks "today". + */ +export function maxDueIso(now: Date = new Date()): string { + return new Date(now.getTime() + 3 * 365 * 86400000).toISOString().slice(0, 10) +} + +/** A zod `YYYY-MM-DD` date string, refined to be no more than 3 years out. */ +export const dueDateField = z + .string() + .date() + .refine((d) => d <= maxDueIso(), { message: "dueDate too far in the future" }) diff --git a/src/lib/plan/tasks.test.ts b/src/lib/plan/tasks.test.ts index 5753009..8939ca2 100644 --- a/src/lib/plan/tasks.test.ts +++ b/src/lib/plan/tasks.test.ts @@ -1,42 +1,121 @@ import { describe, expect, it } from "vitest" -import { buildTaskDrafts, sourceKey, type TaskGenInput } from "./tasks" +import { buildTaskDrafts, reviewDays, sourceKey, type TaskGenInput } from "./tasks" import type { GoalConfig } from "@/db/schema/studies" +/** Anchor "today" far ahead of the exams so the runway math is deterministic. */ +const TODAY = "2026-01-01" + function base(over: Partial = {}): TaskGenInput { return { goals: [], outlineTopics: [], assignments: [], milestones: [], ...over } } const cfg = (c: Partial = {}): GoalConfig => c -describe("buildTaskDrafts", () => { - it("exam goal → one learning task per outline topic + review tasks", () => { +const daysBetween = (from: string, to: string) => + Math.round((Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / 86400000) + +describe("reviewDays", () => { + it("uses an explicit value, clamped to [3,14]", () => { + expect(reviewDays("2026-09-01", TODAY, 7)).toBe(7) + expect(reviewDays("2026-09-01", TODAY, 1)).toBe(3) // floored + expect(reviewDays("2026-09-01", TODAY, 30)).toBe(14) // capped + }) + + it("derives 25% of the runway, clamped to [3,14]", () => { + // ~243 days out → 0.25×243 ≈ 61 → capped at 14 + expect(reviewDays("2026-09-01", TODAY)).toBe(14) + // 40 days out → 0.25×40 = 10 + expect(reviewDays("2026-02-10", TODAY)).toBe(10) + // 4 days out → 0.25×4 = 1 → floored to 3 + expect(reviewDays("2026-01-05", TODAY)).toBe(3) + // in the past / today → floored to 3 + expect(reviewDays("2026-01-01", TODAY)).toBe(3) + }) +}) + +describe("buildTaskDrafts — exam consolidation window", () => { + it("learning tasks are due BEFORE the window (exam − reviewDays)", () => { + const exam = "2026-09-01" const drafts = buildTaskDrafts( base({ - goals: [{ id: "g1", type: "exam", title: "Klausur", dueDate: "2026-09-01", config: cfg() }], + goals: [{ id: "g1", type: "exam", title: "Klausur", dueDate: exam, config: cfg() }], outlineTopics: [ { id: "top1", title: "Vektoren", weight: 8 }, { id: "top2", title: "Matrizen", weight: 4 }, ], - }) + }), + TODAY ) + const rd = reviewDays(exam, TODAY) // 14 + const windowStart = new Date(Date.parse(`${exam}T00:00:00Z`) - rd * 86400000) + .toISOString() + .slice(0, 10) + const topicTasks = drafts.filter((d) => d.source.kind === "outline_topic") expect(topicTasks).toHaveLength(2) expect(topicTasks[0].source.refId).toBe("top1") - expect(topicTasks[0].dueDate).toBe("2026-09-01") - // heavier topic gets more minutes + expect(topicTasks[0].category).toBe("learn") + expect(topicTasks.every((t) => t.dueDate === windowStart)).toBe(true) expect(topicTasks[0].estimatedMinutes).toBeGreaterThan(topicTasks[1].estimatedMinutes) - // review tasks present - expect(drafts.some((d) => d.source.refId === "review-g1")).toBe(true) - expect(drafts.some((d) => d.source.refId === "mock-g1")).toBe(true) }) - it("exam goal without outline → single grounding task", () => { + it("emits a spaced review series (+cards) sized by reviewDays, plus one mock", () => { + const exam = "2026-09-01" + const drafts = buildTaskDrafts( + base({ + goals: [{ id: "g1", type: "exam", title: null, dueDate: exam, config: cfg() }], + outlineTopics: [{ id: "t", title: "A", weight: 5 }], + }), + TODAY + ) + const rd = reviewDays(exam, TODAY) // 14 + const n = Math.max(2, Math.min(5, Math.ceil(rd / 3))) // 5 + + const reviews = drafts.filter((d) => (d.source.refId ?? "").startsWith("review-")) + const cards = drafts.filter((d) => (d.source.refId ?? "").startsWith("cards-")) + const mocks = drafts.filter((d) => (d.source.refId ?? "").startsWith("mock-")) + + expect(reviews).toHaveLength(n) + expect(cards).toHaveLength(n) + expect(mocks).toHaveLength(1) + expect(reviews.every((r) => r.category === "review")).toBe(true) + expect(cards.every((c) => c.category === "cards")).toBe(true) + expect(mocks[0].category).toBe("review") + + // Every review/cards task lands inside [exam−rd, exam]; mock the day before. + const start = new Date(Date.parse(`${exam}T00:00:00Z`) - rd * 86400000).toISOString().slice(0, 10) + for (const d of [...reviews, ...cards]) { + expect(d.dueDate! >= start && d.dueDate! <= exam).toBe(true) + } + expect(daysBetween(mocks[0].dueDate!, exam)).toBe(1) + }) + + it("series count is clamped to [2,5]", () => { + // 5 days out → rd=3 → ceil(1)=1 → clamped to 2 reviews const drafts = buildTaskDrafts( - base({ goals: [{ id: "g1", type: "exam", title: null, dueDate: null, config: cfg() }] }) + base({ + goals: [{ id: "g", type: "exam", title: null, dueDate: "2026-01-06", config: cfg() }], + outlineTopics: [{ id: "t", title: "A", weight: 5 }], + }), + TODAY + ) + expect(drafts.filter((d) => (d.source.refId ?? "").startsWith("review-"))).toHaveLength(2) + }) + + it("no outline → single grounding task due before the window", () => { + const exam = "2026-09-01" + const drafts = buildTaskDrafts( + base({ goals: [{ id: "g1", type: "exam", title: null, dueDate: exam, config: cfg() }] }), + TODAY ) expect(drafts).toHaveLength(1) expect(drafts[0].source.refId).toBe("study-g1") + expect(drafts[0].category).toBe("learn") + const rd = reviewDays(exam, TODAY) + expect(daysBetween(drafts[0].dueDate!, exam)).toBe(rd) }) +}) +describe("buildTaskDrafts — other goal types", () => { it("assignments goal → one task per open assignment with its due date", () => { const drafts = buildTaskDrafts( base({ @@ -45,12 +124,14 @@ describe("buildTaskDrafts", () => { { id: "a1", title: "Blatt 1", dueDate: "2026-08-10" }, { id: "a2", title: "Blatt 2", dueDate: "2026-08-17" }, ], - }) + }), + TODAY ) expect(drafts).toHaveLength(2) expect(drafts.map((d) => d.source.refId)).toEqual(["a1", "a2"]) expect(drafts[0].dueDate).toBe("2026-08-10") expect(drafts[0].goalId).toBe("g2") + expect(drafts.every((d) => d.category === "learn")).toBe(true) }) it("assignments goal without sheets → expectedCount placeholders", () => { @@ -59,7 +140,8 @@ describe("buildTaskDrafts", () => { goals: [ { id: "g2", type: "assignments", title: null, dueDate: null, config: cfg({ expectedCount: 3 }) }, ], - }) + }), + TODAY ) expect(drafts).toHaveLength(3) expect(drafts.every((d) => d.source.kind === "ai")).toBe(true) @@ -70,7 +152,8 @@ describe("buildTaskDrafts", () => { base({ goals: [{ id: "g3", type: "term_paper", title: null, dueDate: "2026-09-30", config: cfg() }], milestones: [{ id: "m1", title: "Exposé", description: "x", dueDate: "2026-08-20" }], - }) + }), + TODAY ) expect(withMilestones).toHaveLength(1) expect(withMilestones[0].source).toEqual({ kind: "milestone", refId: "m1" }) @@ -80,11 +163,11 @@ describe("buildTaskDrafts", () => { goals: [ { id: "g3", type: "term_paper", title: null, dueDate: "2026-09-30", config: cfg({ variant: "task" }) }, ], - }) + }), + TODAY ) expect(noMilestones.length).toBeGreaterThan(0) expect(noMilestones.every((d) => d.source.kind === "ai")).toBe(true) - // last phase task carries the goal due date expect(noMilestones[noMilestones.length - 1].dueDate).toBe("2026-09-30") }) @@ -94,27 +177,31 @@ describe("buildTaskDrafts", () => { goals: [ { id: "g4", type: "presentation", title: null, dueDate: "2026-08-15", config: cfg({ durationMinutes: 20 }) }, ], - }) + }), + TODAY ) expect(drafts.map((d) => d.source.refId)).toEqual(["prep-g4", "rehearse-g4"]) expect(drafts.every((d) => d.dueDate === "2026-08-15")).toBe(true) }) - it("produces stable, unique source keys for idempotent upserts", () => { + it("other goal type yields no tasks", () => { + const drafts = buildTaskDrafts( + base({ goals: [{ id: "g5", type: "other", title: null, dueDate: null, config: cfg() }] }), + TODAY + ) + expect(drafts).toHaveLength(0) + }) +}) + +describe("buildTaskDrafts — idempotent source keys", () => { + it("produces stable, unique source keys across runs", () => { const input = base({ - goals: [{ id: "g1", type: "exam", title: null, dueDate: null, config: cfg() }], + goals: [{ id: "g1", type: "exam", title: null, dueDate: "2026-09-01", config: cfg() }], outlineTopics: [{ id: "top1", title: "A", weight: 5 }], }) - const first = buildTaskDrafts(input).map((d) => sourceKey(d.source)) - const second = buildTaskDrafts(input).map((d) => sourceKey(d.source)) + const first = buildTaskDrafts(input, TODAY).map((d) => sourceKey(d.source)) + const second = buildTaskDrafts(input, TODAY).map((d) => sourceKey(d.source)) expect(second).toEqual(first) expect(new Set(first).size).toBe(first.length) // all unique }) - - it("other goal type yields no tasks", () => { - const drafts = buildTaskDrafts( - base({ goals: [{ id: "g5", type: "other", title: null, dueDate: null, config: cfg() }] }) - ) - expect(drafts).toHaveLength(0) - }) }) diff --git a/src/lib/plan/tasks.ts b/src/lib/plan/tasks.ts index dbb7c13..eede122 100644 --- a/src/lib/plan/tasks.ts +++ b/src/lib/plan/tasks.ts @@ -10,6 +10,9 @@ import type { PlanTaskSource } from "@/db/schema/plan" * action upserts by (kind, refId) and never duplicates or clobbers manual work. */ +/** What a draft is for; drives the pre-exam consolidation window scheduling. */ +export type PlanTaskCategory = "learn" | "review" | "cards" + export type PlanTaskDraft = { title: string description: string | null @@ -17,6 +20,7 @@ export type PlanTaskDraft = { dueDate: string | null goalId: string | null source: PlanTaskSource + category: PlanTaskCategory aiGenerated: boolean } @@ -40,6 +44,29 @@ export type TaskGenInput = { const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n)) +/** UTC-based day math so it never depends on the host timezone/clock. */ +const MS_PER_DAY = 86400000 +function parseDay(date: string): number { + return Date.parse(`${date}T00:00:00Z`) +} +function addDays(date: string, n: number): string { + return new Date(parseDay(date) + n * MS_PER_DAY).toISOString().slice(0, 10) +} +function daysBetween(from: string, to: string): number { + return Math.round((parseDay(to) - parseDay(from)) / MS_PER_DAY) +} + +/** + * Length of the pre-exam consolidation window in days: an explicit value if the + * goal carries one, otherwise `clamp(round(0.25 × daysUntilExam), 3, 14)` — a + * quarter of the remaining runway, floored at 3 and capped at 14 days. Pure. + */ +export function reviewDays(examDate: string, today: string, explicit?: number | null): number { + if (explicit != null && Number.isFinite(explicit)) return clamp(Math.round(explicit), 3, 14) + const until = Math.max(0, daysBetween(today, examDate)) + return clamp(Math.round(0.25 * until), 3, 14) +} + /** Outline weight (1–10) → estimated study minutes (30–150), rounded to 15. */ function minutesForTopic(weight: number): number { const raw = clamp(weight, 1, 10) * 15 @@ -61,50 +88,82 @@ const TASK_PHASES = [ "Überarbeitung & Korrektur", ] -function examDrafts(goal: TaskGenGoal, topics: TaskGenInput["outlineTopics"]): PlanTaskDraft[] { +function examDrafts( + goal: TaskGenGoal, + topics: TaskGenInput["outlineTopics"], + today: string +): PlanTaskDraft[] { const drafts: PlanTaskDraft[] = [] + const examDate = goal.dueDate + // Effective learning deadline sits BEFORE the consolidation window so new + // material is done by the time review starts. Falls back to the exam date + // (or null) when there is no exam date to anchor the window. + const rd = examDate ? reviewDays(examDate, today, goal.config.reviewDays) : null + const learnDue = examDate && rd != null ? addDays(examDate, -rd) : examDate + if (topics.length > 0) { for (const topic of topics) { drafts.push({ title: `Lernen: ${topic.title}`, description: null, estimatedMinutes: minutesForTopic(topic.weight), - dueDate: goal.dueDate, + dueDate: learnDue, goalId: goal.id, source: { kind: "outline_topic", refId: topic.id }, + category: "learn", aiGenerated: false, }) } - // Review tasks in the run-up to the exam (cards/quiz recommendation). - drafts.push( - { - title: "Wiederholung & Karteikarten", - description: "Fällige Karten wiederholen und Wissenslücken schließen.", - estimatedMinutes: 60, - dueDate: goal.dueDate, - goalId: goal.id, - source: { kind: "ai", refId: `review-${goal.id}` }, - aiGenerated: false, - }, - { + // Spaced consolidation series inside [examDate−reviewDays, examDate]: n + // review tasks + a companion cards task each, plus one mock the day before. + if (examDate && rd != null) { + const windowStart = addDays(examDate, -rd) + const n = clamp(Math.ceil(rd / 3), 2, 5) + for (let i = 0; i < n; i++) { + const offset = clamp(Math.round(((i + 1) / n) * rd), 1, rd) + const due = addDays(windowStart, offset) + drafts.push({ + title: `Wiederholung ${i + 1}/${n}`, + description: "Kernthemen aktiv wiederholen und Wissenslücken schließen.", + estimatedMinutes: 60, + dueDate: due, + goalId: goal.id, + source: { kind: "ai", refId: `review-${goal.id}-${i}` }, + category: "review", + aiGenerated: false, + }) + drafts.push({ + title: `Karten & Fehler-Deck ${i + 1}/${n}`, + description: "Fällige Karteikarten und Karten aus dem Fehler-Deck üben.", + estimatedMinutes: 30, + dueDate: due, + goalId: goal.id, + source: { kind: "ai", refId: `cards-${goal.id}-${i}` }, + category: "cards", + aiGenerated: false, + }) + } + drafts.push({ title: "Probeklausur / Quiz", description: "Unter Prüfungsbedingungen testen.", estimatedMinutes: 90, - dueDate: goal.dueDate, + dueDate: addDays(examDate, -1), goalId: goal.id, source: { kind: "ai", refId: `mock-${goal.id}` }, + category: "review", aiGenerated: false, - } - ) + }) + } } else { // No outline yet — a single grounding task the action may enrich via RAG. drafts.push({ title: "Materialien durcharbeiten", description: "Materialien sichten und Kernthemen zusammenfassen.", estimatedMinutes: 120, - dueDate: goal.dueDate, + dueDate: learnDue, goalId: goal.id, source: { kind: "ai", refId: `study-${goal.id}` }, + category: "learn", aiGenerated: false, }) } @@ -123,6 +182,7 @@ function assignmentDrafts( dueDate: a.dueDate, goalId: goal.id, source: { kind: "assignment", refId: a.id }, + category: "learn" as const, aiGenerated: false, })) } @@ -135,6 +195,7 @@ function assignmentDrafts( dueDate: null, goalId: goal.id, source: { kind: "ai" as const, refId: `assignment-${goal.id}-${i}` }, + category: "learn" as const, aiGenerated: false, })) } @@ -148,6 +209,7 @@ function writingDrafts(goal: TaskGenGoal, milestones: TaskGenInput["milestones"] dueDate: m.dueDate, goalId: goal.id, source: { kind: "milestone", refId: m.id }, + category: "learn" as const, aiGenerated: false, })) } @@ -160,6 +222,7 @@ function writingDrafts(goal: TaskGenGoal, milestones: TaskGenInput["milestones"] dueDate: i === phases.length - 1 ? goal.dueDate : null, goalId: goal.id, source: { kind: "ai" as const, refId: `phase-${goal.id}-${i}` }, + category: "learn" as const, aiGenerated: false, })) } @@ -174,6 +237,7 @@ function presentationDrafts(goal: TaskGenGoal): PlanTaskDraft[] { dueDate: goal.dueDate, goalId: goal.id, source: { kind: "ai", refId: `prep-${goal.id}` }, + category: "learn", aiGenerated: false, }, { @@ -183,19 +247,23 @@ function presentationDrafts(goal: TaskGenGoal): PlanTaskDraft[] { dueDate: goal.dueDate, goalId: goal.id, source: { kind: "ai", refId: `rehearse-${goal.id}` }, + category: "learn", aiGenerated: false, }, ] } -/** Builds the full set of proposed drafts for a module from its goals + data. */ -export function buildTaskDrafts(input: TaskGenInput): PlanTaskDraft[] { +/** + * Builds the full set of proposed drafts for a module from its goals + data. + * `today` (ISO date) anchors the pre-exam consolidation window math. + */ +export function buildTaskDrafts(input: TaskGenInput, today: string): PlanTaskDraft[] { const drafts: PlanTaskDraft[] = [] for (const goal of input.goals) { switch (goal.type) { case "exam": case "oral_exam": - drafts.push(...examDrafts(goal, input.outlineTopics)) + drafts.push(...examDrafts(goal, input.outlineTopics, today)) break case "assignments": drafts.push(...assignmentDrafts(goal, input.assignments)) diff --git a/src/lib/search/query.test.ts b/src/lib/search/query.test.ts new file mode 100644 index 0000000..737388f --- /dev/null +++ b/src/lib/search/query.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest" +import { escapeLike, likePattern } from "./query" + +describe("escapeLike", () => { + it("escapes LIKE wildcards so user input matches literally", () => { + expect(escapeLike("50%")).toBe("50\\%") + expect(escapeLike("a_b")).toBe("a\\_b") + expect(escapeLike("c:\\path")).toBe("c:\\\\path") + }) + + it("leaves ordinary text untouched", () => { + expect(escapeLike("Analysis")).toBe("Analysis") + }) +}) + +describe("likePattern", () => { + it("wraps the escaped term in wildcards", () => { + expect(likePattern("foo")).toBe("%foo%") + expect(likePattern("100%")).toBe("%100\\%%") + }) +}) diff --git a/src/lib/search/query.ts b/src/lib/search/query.ts new file mode 100644 index 0000000..d400ea0 --- /dev/null +++ b/src/lib/search/query.ts @@ -0,0 +1,15 @@ +/** + * Pure helpers for the command-palette search route. Kept DB-free so the + * escaping rules are unit-tested (see query.test.ts). + */ + +/** Escapes the LIKE/ILIKE wildcard metacharacters so user input matches + * literally (backslash is the default ESCAPE char in Postgres LIKE). */ +export function escapeLike(input: string): string { + return input.replace(/[\\%_]/g, "\\$&") +} + +/** Builds a `%…%` ILIKE pattern with wildcards in the term escaped. */ +export function likePattern(input: string): string { + return `%${escapeLike(input)}%` +} diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 6b2edad..2ad0cbf 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -4,6 +4,7 @@ import { z } from "zod" import { db } from "@/db" import { appConfig } from "@/db/schema" import { decrypt, encrypt } from "./crypto" +import { createTtlCache } from "./ttl-cache" // ---- Setting schemas ------------------------------------------------------- @@ -184,7 +185,35 @@ const defaults: { [K in SettingKey]: SettingValue } = { // ---- Store ----------------------------------------------------------------- +/** + * Short-TTL in-process cache for resolved (already decrypted + validated) + * settings. `getSetting` is on very hot paths — every RAG search reads `ai` 3×, + * every chat turn reads it — and each miss hits the DB and AES-decrypts. A 30s + * TTL keeps admin-panel changes near-instant while collapsing the repeated + * reads. Survives HMR via a global (mirrors the lazy auth/db singletons) and is + * invalidated eagerly on write. The cached value is the post-decrypt result, so + * a hit never re-decrypts. + */ +const SETTINGS_TTL_MS = 30_000 +const globalForSettings = globalThis as unknown as { + settingsCache?: ReturnType> +} +const settingsCache = (globalForSettings.settingsCache ??= createTtlCache(SETTINGS_TTL_MS)) + +/** Drops the settings cache (all keys). Call when settings change out-of-band. */ +export function bustSettingsCache(): void { + settingsCache.clear() +} + export async function getSetting(key: K): Promise | null> { + const cached = settingsCache.get(key) + if (cached !== undefined) return cached as SettingValue | null + const value = await loadSetting(key) + settingsCache.set(key, value) + return value +} + +async function loadSetting(key: K): Promise | null> { const row = await db.query.appConfig.findFirst({ where: eq(appConfig.key, key) }) if (!row) return (defaults[key] ?? null) as SettingValue | null let raw = row.value @@ -208,10 +237,12 @@ export async function setSetting( target: appConfig.key, set: { value: stored, updatedAt: new Date() }, }) + settingsCache.delete(key) } export async function deleteSetting(key: SettingKey): Promise { await db.delete(appConfig).where(eq(appConfig.key, key)) + settingsCache.delete(key) } /** The configured app name (branding), falling back to "StudyHelper". */ diff --git a/src/lib/studies/orphan-cleanup.test.ts b/src/lib/studies/orphan-cleanup.test.ts new file mode 100644 index 0000000..3d56565 --- /dev/null +++ b/src/lib/studies/orphan-cleanup.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest" +import { collectStoragePaths } from "./storage-paths" + +describe("collectStoragePaths", () => { + it("collects both the file blob and the extracted-text blob of each material", () => { + expect( + collectStoragePaths([ + { storagePath: "u1/a.pdf", textStoragePath: "u1/a.txt" }, + { storagePath: "u1/b.png", textStoragePath: "u1/b.txt" }, + ]) + ).toEqual(["u1/a.pdf", "u1/a.txt", "u1/b.png", "u1/b.txt"]) + }) + + it("skips null paths (link materials, un-extracted files)", () => { + expect( + collectStoragePaths([ + { storagePath: null, textStoragePath: null }, // link material + { storagePath: "u1/c.pdf", textStoragePath: null }, // not yet extracted + { storagePath: null, textStoragePath: "u1/orphan.txt" }, + ]) + ).toEqual(["u1/c.pdf", "u1/orphan.txt"]) + }) + + it("returns an empty list for no rows", () => { + expect(collectStoragePaths([])).toEqual([]) + }) +}) diff --git a/src/lib/studies/orphan-cleanup.ts b/src/lib/studies/orphan-cleanup.ts new file mode 100644 index 0000000..4343419 --- /dev/null +++ b/src/lib/studies/orphan-cleanup.ts @@ -0,0 +1,67 @@ +import "server-only" +import { inArray } from "drizzle-orm" +import { db } from "@/db" +import { material, semester, studyModule } from "@/db/schema" +import { collectStoragePaths } from "./storage-paths" + +export { collectStoragePaths } from "./storage-paths" + +/** All storage paths owned by materials under the given modules. */ +async function pathsForModules(moduleIds: string[]): Promise { + if (moduleIds.length === 0) return [] + const rows = await db.query.material.findMany({ + where: inArray(material.moduleId, moduleIds), + columns: { storagePath: true, textStoragePath: true }, + }) + return collectStoragePaths(rows) +} + +/** Module ids under a semester. */ +async function moduleIdsForSemester(semesterId: string): Promise { + const rows = await db.query.studyModule.findMany({ + where: inArray(studyModule.semesterId, [semesterId]), + columns: { id: true }, + }) + return rows.map((r) => r.id) +} + +/** Module ids under a whole program (across all its semesters). */ +async function moduleIdsForProgram(programId: string): Promise { + const sems = await db.query.semester.findMany({ + where: inArray(semester.programId, [programId]), + columns: { id: true }, + }) + const semesterIds = sems.map((s) => s.id) + if (semesterIds.length === 0) return [] + const rows = await db.query.studyModule.findMany({ + where: inArray(studyModule.semesterId, semesterIds), + columns: { id: true }, + }) + return rows.map((r) => r.id) +} + +/** + * Enqueues deletion of every storage object owned by materials in a + * program/semester/module subtree. MUST be called BEFORE the DB cascade delete + * removes the material rows — afterwards the paths are unrecoverable. The actual + * unlink runs in the `sweep-orphan-files` background job (bounded + crash-safe: + * a retry that finds a file already gone is a no-op). A leak in the crash window + * between row-delete and enqueue is harmless (the blob is simply orphaned). + */ +export async function sweepModuleFiles(moduleId: string): Promise { + await enqueuePaths(await pathsForModules([moduleId])) +} + +export async function sweepSemesterFiles(semesterId: string): Promise { + await enqueuePaths(await pathsForModules(await moduleIdsForSemester(semesterId))) +} + +export async function sweepProgramFiles(programId: string): Promise { + await enqueuePaths(await pathsForModules(await moduleIdsForProgram(programId))) +} + +async function enqueuePaths(paths: string[]): Promise { + if (paths.length === 0) return + const { enqueueSweepOrphanFiles } = await import("@/lib/jobs") + await enqueueSweepOrphanFiles(paths) +} diff --git a/src/lib/studies/storage-paths.ts b/src/lib/studies/storage-paths.ts new file mode 100644 index 0000000..555abd8 --- /dev/null +++ b/src/lib/studies/storage-paths.ts @@ -0,0 +1,16 @@ +/** The stored-file columns a material can own on disk. */ +export type MaterialPaths = { storagePath: string | null; textStoragePath: string | null } + +/** + * Flattens a set of material rows into the list of storage objects they own — + * both the original file blob and the extracted-text blob. Pure and DB-free so + * the collection logic is unit-testable; the DB query lives in `orphan-cleanup`. + */ +export function collectStoragePaths(rows: MaterialPaths[]): string[] { + const paths: string[] = [] + for (const row of rows) { + if (row.storagePath) paths.push(row.storagePath) + if (row.textStoragePath) paths.push(row.textStoragePath) + } + return paths +} diff --git a/src/lib/ttl-cache.test.ts b/src/lib/ttl-cache.test.ts new file mode 100644 index 0000000..d2659fb --- /dev/null +++ b/src/lib/ttl-cache.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest" +import { createTtlCache } from "./ttl-cache" + +describe("createTtlCache", () => { + it("returns undefined for a missing key", () => { + const cache = createTtlCache(1000) + expect(cache.get("nope")).toBeUndefined() + }) + + it("returns a cached value within the TTL window", () => { + let clock = 0 + const cache = createTtlCache(30_000, () => clock) + cache.set("k", "v") + clock = 29_999 + expect(cache.get("k")).toBe("v") + }) + + it("evicts and misses once the TTL has elapsed", () => { + let clock = 0 + const cache = createTtlCache(30_000, () => clock) + cache.set("k", "v") + clock = 30_000 // exactly at expiry counts as expired + expect(cache.get("k")).toBeUndefined() + // a re-set after expiry starts a fresh window + cache.set("k", "v2") + clock = 45_000 + expect(cache.get("k")).toBe("v2") + clock = 60_001 + expect(cache.get("k")).toBeUndefined() + }) + + it("caches null distinctly from a miss", () => { + const cache = createTtlCache(1000) + cache.set("k", null) + expect(cache.get("k")).toBeNull() + expect(cache.get("other")).toBeUndefined() + }) + + it("delete removes a single entry, clear empties the cache", () => { + const cache = createTtlCache(1000) + cache.set("a", 1) + cache.set("b", 2) + cache.delete("a") + expect(cache.get("a")).toBeUndefined() + expect(cache.get("b")).toBe(2) + cache.clear() + expect(cache.get("b")).toBeUndefined() + }) +}) diff --git a/src/lib/ttl-cache.ts b/src/lib/ttl-cache.ts new file mode 100644 index 0000000..7e7c95c --- /dev/null +++ b/src/lib/ttl-cache.ts @@ -0,0 +1,39 @@ +/** + * A minimal in-process TTL cache. Values live for `ttlMs` from the moment they + * are set; a `get` past expiry evicts the entry and misses. `now` is injectable + * so the TTL behaviour is unit-testable with a fake clock. + * + * Intended for short-lived caching of hot, rarely-changing reads (e.g. app + * settings) — NOT a general-purpose LRU. `null` is a cacheable value (distinct + * from a miss, which returns `undefined`). + */ +export type TtlCache = { + get(key: string): V | undefined + set(key: string, value: V): void + delete(key: string): void + clear(): void +} + +export function createTtlCache(ttlMs: number, now: () => number = Date.now): TtlCache { + const store = new Map() + return { + get(key) { + const entry = store.get(key) + if (!entry) return undefined + if (entry.expires <= now()) { + store.delete(key) + return undefined + } + return entry.value + }, + set(key, value) { + store.set(key, { value, expires: now() + ttlMs }) + }, + delete(key) { + store.delete(key) + }, + clear() { + store.clear() + }, + } +}