From 0a6a4b2721c0a33ee1d38701e41cec749b894041 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 12:44:17 +0000 Subject: [PATCH 1/9] fix(security): plan/auth/upload hardening (audit phase R1) - plan scheduler: cap schedule horizon at today+365d and clamp task dueDate to <=today+3y (DoS via far-future deadline); degenerate session-config guard + slot-loop backstop; expandAbsences clamps range to <=400 days - plan tasks: validate client goalId belongs to the module (IDOR) - availability: strict HH:mm regex + from Claude-Session: https://claude.ai/code/session_012Nrz9bERddhwVXWuZyVBn7 --- src/app/[locale]/(app)/plan/actions.ts | 60 ++++++++++--------- .../[locale]/(app)/plan/plan-task-actions.ts | 21 ++++++- .../[locale]/(app)/plan/schedule-actions.ts | 7 ++- src/app/api/health/route.ts | 34 +++++++++-- .../api/materials/tus/[[...slug]]/route.ts | 18 +++++- src/lib/audit.ts | 20 ++++++- src/lib/auth/index.ts | 21 ++++++- src/lib/plan/absences.ts | 5 ++ src/lib/plan/scheduler.test.ts | 29 +++++++++ src/lib/plan/scheduler.ts | 19 +++++- src/lib/plan/task-validation.test.ts | 25 ++++++++ src/lib/plan/task-validation.ts | 16 +++++ 12 files changed, 233 insertions(+), 42 deletions(-) create mode 100644 src/lib/plan/task-validation.test.ts create mode 100644 src/lib/plan/task-validation.ts diff --git a/src/app/[locale]/(app)/plan/actions.ts b/src/app/[locale]/(app)/plan/actions.ts index 3a048b9..ab3b8cf 100644 --- a/src/app/[locale]/(app)/plan/actions.ts +++ b/src/app/[locale]/(app)/plan/actions.ts @@ -8,14 +8,18 @@ import { requireSession } from "@/lib/auth/session" import { validateCron } from "@/lib/plan/absences" import { ownSemester } from "@/lib/studies/access" +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 +33,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(), diff --git a/src/app/[locale]/(app)/plan/plan-task-actions.ts b/src/app/[locale]/(app)/plan/plan-task-actions.ts index bbad3c5..c4e098d 100644 --- a/src/app/[locale]/(app)/plan/plan-task-actions.ts +++ b/src/app/[locale]/(app)/plan/plan-task-actions.ts @@ -17,6 +17,7 @@ import { 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" type OwnedModule = Awaited> @@ -26,6 +27,20 @@ function revalidateModule(mod: OwnedModule) { revalidatePath("/", "layout") } +/** + * 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)) throw new Error("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({ @@ -182,7 +197,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 +205,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), @@ -217,7 +233,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 +241,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({ diff --git a/src/app/[locale]/(app)/plan/schedule-actions.ts b/src/app/[locale]/(app)/plan/schedule-actions.ts index bab5a46..b9d52bc 100644 --- a/src/app/[locale]/(app)/plan/schedule-actions.ts +++ b/src/app/[locale]/(app)/plan/schedule-actions.ts @@ -69,13 +69,18 @@ export async function recomputeSchedule(semesterId: string) { })) .filter((m) => m.tasks.length > 0) - // Horizon: at least 6 weeks, extended to the last task deadline if further. + // 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. const now = new Date() const today = toIsoDate(now) 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 + if (horizonEnd > cap) horizonEnd = cap const horizonDate = new Date(`${horizonEnd}T23:59:59`) // Absences (blackouts + recurring) → all-day / windowed blockers. 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/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/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..a8a5a08 100644 --- a/src/lib/auth/index.ts +++ b/src/lib/auth/index.ts @@ -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) @@ -101,6 +117,7 @@ function buildAuth(config: DynamicAuthConfig) { clientId: p.clientId, clientSecret: p.clientSecret, scopes: p.scopes, + ...implicitSignup, })), }), nextCookies(), // must be last 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/scheduler.test.ts b/src/lib/plan/scheduler.test.ts index c6462a3..53e17fa 100644 --- a/src/lib/plan/scheduler.test.ts +++ b/src/lib/plan/scheduler.test.ts @@ -335,3 +335,32 @@ describe("DEFAULT_SCHEDULE_CONFIG", () => { }) }) }) + +// ---- 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..7591c4b 100644 --- a/src/lib/plan/scheduler.ts +++ b/src/lib/plan/scheduler.ts @@ -166,7 +166,19 @@ 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[] = [] @@ -231,7 +243,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. 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" }) From d55534fd08daa4cbcfecca55a5d81bec249c53e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 13:04:18 +0000 Subject: [PATCH 2/9] feat(plan): pre-exam consolidation window + plan correctness (audit R2) The study plan now enforces "learn early, consolidate before the exam": - migration 0041 (additive): plan_session.kind (study|review|cards), semester_plan.week_overrides/stale_at/last_warnings, event.goal_id, and GoalConfig.reviewDays - tasks: learning tasks are due exam-reviewDays; a spaced review series + companion FSRS/mistakes "cards" tasks + a mock fill the window - scheduler: no learn task inside the consolidation window (assignment due in-window excepted), only review/cards there, no session on exam day, per-week capacity caps, horizon_clipped warning, earliestDue hoisted - recompute uses collectReplannableTasks (keeps pinned/done future sessions, catches up past-undone tasks); session kind persisted; warnings persisted - toggleSession completes/uncompletes its tasks; staleness flag on every plan mutation; auto-replan only on availability save, exam-date change, session done - exam goals sync an all-day exam calendar event (blocks the exam day) - task regeneration reconciles stale/removed generated tasks - reminders use local today; calendar bounds its plan-session query Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012Nrz9bERddhwVXWuZyVBn7 --- drizzle/0041_plan_consolidation.sql | 28 + drizzle/meta/0041_snapshot.json | 6025 +++++++++++++++++ drizzle/meta/_journal.json | 7 + src/app/[locale]/(app)/calendar/page.tsx | 14 +- src/app/[locale]/(app)/plan/actions.ts | 7 + .../[locale]/(app)/plan/plan-task-actions.ts | 93 +- .../[locale]/(app)/plan/schedule-actions.ts | 132 +- .../[locale]/(app)/studies/goal-actions.ts | 91 +- src/db/schema/plan.ts | 10 + src/db/schema/semester-plan.ts | 14 + src/db/schema/studies.ts | 10 + src/lib/jobs/reminders.ts | 3 +- src/lib/plan/replan.test.ts | 51 + src/lib/plan/replan.ts | 43 + src/lib/plan/scheduler.test.ts | 188 + src/lib/plan/scheduler.ts | 136 +- src/lib/plan/staleness.ts | 17 + src/lib/plan/tasks.test.ts | 145 +- src/lib/plan/tasks.ts | 110 +- 19 files changed, 7019 insertions(+), 105 deletions(-) create mode 100644 drizzle/0041_plan_consolidation.sql create mode 100644 drizzle/meta/0041_snapshot.json create mode 100644 src/lib/plan/replan.test.ts create mode 100644 src/lib/plan/replan.ts create mode 100644 src/lib/plan/staleness.ts 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/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/_journal.json b/drizzle/meta/_journal.json index 2544c24..4cfd812 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -288,6 +288,13 @@ "when": 1784402733865, "tag": "0040_plan_cutover", "breakpoints": true + }, + { + "idx": 41, + "version": "7", + "when": 1784466080725, + "tag": "0041_plan_consolidation", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/[locale]/(app)/calendar/page.tsx b/src/app/[locale]/(app)/calendar/page.tsx index 8738bb2..708834f 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, @@ -32,6 +33,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 +51,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: { diff --git a/src/app/[locale]/(app)/plan/actions.ts b/src/app/[locale]/(app)/plan/actions.ts index ab3b8cf..06e212b 100644 --- a/src/app/[locale]/(app)/plan/actions.ts +++ b/src/app/[locale]/(app)/plan/actions.ts @@ -7,6 +7,7 @@ import { semesterPlan, type PlanAvailability } from "@/db/schema" import { requireSession } from "@/lib/auth/session" import { validateCron } from "@/lib/plan/absences" import { ownSemester } from "@/lib/studies/access" +import { recomputeSchedule } from "./schedule-actions" const HHMM = /^([01]\d|2[0-3]):[0-5]\d$/ @@ -70,6 +71,12 @@ 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 } } diff --git a/src/app/[locale]/(app)/plan/plan-task-actions.ts b/src/app/[locale]/(app)/plan/plan-task-actions.ts index c4e098d..bc71a92 100644 --- a/src/app/[locale]/(app)/plan/plan-task-actions.ts +++ b/src/app/[locale]/(app)/plan/plan-task-actions.ts @@ -18,6 +18,8 @@ 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> @@ -27,6 +29,11 @@ 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 @@ -94,6 +101,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 } } @@ -160,35 +168,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 -------------------------------------------------------------- @@ -225,6 +277,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 } } @@ -252,6 +305,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 } } @@ -260,6 +314,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 } } @@ -268,6 +323,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 } } @@ -287,6 +343,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 b9d52bc..2770884 100644 --- a/src/app/[locale]/(app)/plan/schedule-actions.ts +++ b/src/app/[locale]/(app)/plan/schedule-actions.ts @@ -4,21 +4,42 @@ 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 { 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 @@ -39,21 +60,76 @@ export async function recomputeSchedule(semesterId: string) { 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,6 +141,7 @@ 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) @@ -72,14 +149,12 @@ export async function recomputeSchedule(semesterId: string) { // 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. - const now = new Date() - const today = toIsoDate(now) + // 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`) @@ -116,11 +191,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 })) @@ -130,6 +201,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, @@ -138,6 +216,7 @@ export async function recomputeSchedule(semesterId: string) { busyEvents, config: cfg, pinnedSessions, + weekCapacityOverrides, modules: scheduleModules, } @@ -163,6 +242,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) { @@ -174,7 +254,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 } } @@ -217,8 +301,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)/studies/goal-actions.ts b/src/app/[locale]/(app)/studies/goal-actions.ts index 1392c4a..c797004 100644 --- a/src/app/[locale]/(app)/studies/goal-actions.ts +++ b/src/app/[locale]/(app)/studies/goal-actions.ts @@ -4,9 +4,58 @@ 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 { 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 +148,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 +170,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 } } 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..81517c4 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(), @@ -293,6 +298,7 @@ export const studyEvent = pgTable( (t) => [ index("event_userId_idx").on(t.userId), index("event_startsAt_idx").on(t.startsAt), + index("event_goalId_idx").on(t.goalId), ] ) @@ -364,4 +370,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/lib/jobs/reminders.ts b/src/lib/jobs/reminders.ts index 17f1030..88197ff 100644 --- a/src/lib/jobs/reminders.ts +++ b/src/lib/jobs/reminders.ts @@ -155,7 +155,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 }) 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 53e17fa..e527e64 100644 --- a/src/lib/plan/scheduler.test.ts +++ b/src/lib/plan/scheduler.test.ts @@ -336,6 +336,194 @@ 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", () => { diff --git a/src/lib/plan/scheduler.ts b/src/lib/plan/scheduler.ts index 7591c4b..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 @@ -184,11 +249,14 @@ export function computeSchedule(input: ScheduleInput): ScheduleResult { 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) { @@ -258,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({ @@ -286,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) { @@ -356,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/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)) From dcfb47de69bbbc3101b80e4919862e333df3d926 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 13:22:22 +0000 Subject: [PATCH 3/9] feat(plan): guided setup, staleness, readiness UX (audit R3) - setup-checklist: derives the exam-date -> availability -> tasks -> compute sequence from data; shown on module plan tab, strategy board first run, onboarding CTA - plan banners: "plan is stale" one-click replan + "N missed sessions - catch up"; recompute shows a summary toast with humanized warnings - readiness (pure, tested): exam goal card shows on-track/at-risk/unreachable from remaining task minutes vs capacity-to-exam, plus persisted warnings - week capacity override ("less time this week") in the availability editor - shared module-plan-prefs component (save-on-change) used by both the strategy board and the module plan tab - deterministic actions drop the Sparkles icon; AiBadge on AI-generated tasks; bonus 0-1 fraction + weight helper text; outline nudge - i18n plan.* additions (de/en parity) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012Nrz9bERddhwVXWuZyVBn7 --- messages/de.json | 74 ++++++- messages/en.json | 74 ++++++- .../[locale]/(app)/plan/[semesterId]/page.tsx | 42 +++- src/app/[locale]/(app)/plan/actions.ts | 38 ++++ .../studies/[programId]/[moduleId]/page.tsx | 57 ++++- .../[programId]/[moduleId]/plan/page.tsx | 46 +++- src/components/learn/goal-card.tsx | 76 ++++++- src/components/learn/onboarding-wizard.tsx | 18 ++ src/components/plan/availability-editor.tsx | 103 ++++++++- src/components/plan/module-plan-prefs.tsx | 203 ++++++++++++++++++ src/components/plan/module-plan-view.tsx | 191 ++++------------ src/components/plan/plan-banners.tsx | 63 ++++++ src/components/plan/setup-checklist.tsx | 135 ++++++++++++ src/components/plan/strategy-board.tsx | 159 +++----------- src/components/plan/use-recompute.ts | 39 ++++ src/components/studies/goal-dialog.tsx | 1 + src/components/studies/module-goals-card.tsx | 4 + src/lib/plan/readiness.test.ts | 77 +++++++ src/lib/plan/readiness.ts | 87 ++++++++ 19 files changed, 1184 insertions(+), 303 deletions(-) create mode 100644 src/components/plan/module-plan-prefs.tsx create mode 100644 src/components/plan/plan-banners.tsx create mode 100644 src/components/plan/setup-checklist.tsx create mode 100644 src/components/plan/use-recompute.ts create mode 100644 src/lib/plan/readiness.test.ts create mode 100644 src/lib/plan/readiness.ts diff --git a/messages/de.json b/messages/de.json index 7e79ccc..9129231 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1179,7 +1179,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 +1232,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 +1274,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 +1287,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 +1318,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 +1472,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": { diff --git a/messages/en.json b/messages/en.json index 4485476..ea52894 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1179,7 +1179,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 +1232,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 +1274,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 +1287,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 +1318,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 +1472,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": { 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 06e212b..9f60146 100644 --- a/src/app/[locale]/(app)/plan/actions.ts +++ b/src/app/[locale]/(app)/plan/actions.ts @@ -1,11 +1,13 @@ "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 { 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" @@ -80,3 +82,39 @@ export async function saveAvailability(semesterId: string, input: unknown) { 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) throw new Error("No availability configured") + + 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)/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/components/learn/goal-card.tsx b/src/components/learn/goal-card.tsx index 137ef02..8690aee 100644 --- a/src/components/learn/goal-card.tsx +++ b/src/components/learn/goal-card.tsx @@ -13,8 +13,10 @@ import { Pencil, Plus, Presentation, + Settings2, Target, Trash2, + TriangleAlert, } from "lucide-react" import { useFormatter, useTranslations } from "next-intl" import { toast } from "sonner" @@ -30,6 +32,8 @@ import type { GoalType, GradingSystem, } from "@/db/schema/studies" +import type { Readiness } from "@/lib/plan/readiness" +import type { ScheduleWarning } from "@/lib/plan/scheduler" import { formatGrade } from "@/lib/grades" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" @@ -106,6 +110,32 @@ export type GoalCardData = { assignmentStats: AssignmentStatsDTO | null } +/** + * A7 exam readiness: the traffic light plus any persisted scheduler warnings + * for the module. `null` means there is no plan/availability yet, so the card + * shows a neutral "set up your plan" hint instead of a light. + */ +export type GoalReadinessDTO = { + status: Readiness + warningKinds: ScheduleWarning["kind"][] +} | null + +const READINESS_DOT: Record = { + on_track: "bg-emerald-500", + at_risk: "bg-amber-500", + unreachable: "bg-red-500", +} + +function ReadinessBadge({ status }: { status: Readiness }) { + const t = useTranslations("plan.readiness") + return ( + + + {t(status)} + + ) +} + /** Whole-day difference from today to a "YYYY-MM-DD" date (negative = past). */ function daysUntil(dueDate: string): number { const today = new Date() @@ -205,16 +235,18 @@ function GoalCardShell({ /** Readiness row for exam-like goals: due cards + last quiz score. */ function ReadinessRow({ stats, + dueCardsHref, }: { stats: { dueCards: number; lastQuizScore: number | null } + dueCardsHref: string }) { const t = useTranslations("goalCard") return (
-
+

{t("dueCards")}

{stats.dueCards}

-
+

{t("lastScore")}

@@ -225,6 +257,27 @@ function ReadinessRow({ ) } +/** Persisted scheduler warnings (human text) shown on an exam goal card. */ +function ReadinessWarnings({ kinds }: { kinds: ScheduleWarning["kind"][] }) { + const t = useTranslations("plan") + const seen = new Set() + const unique = kinds.filter((k) => (seen.has(k) ? false : (seen.add(k), true))) + if (unique.length === 0) return null + return ( +

+ {unique.map((kind) => ( +
+ + {t(`warningText.${kind}`)} +
+ ))} +
+ ) +} + /** Attempts table + add/edit/delete dialog + per-goal grade (grade goals). */ function GoalAttempts({ goalId, @@ -486,6 +539,7 @@ export function GoalCard({ gradingSystem, stats, bonus, + readiness, }: { moduleId: string basePath: string @@ -493,8 +547,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 +566,7 @@ export function GoalCard({ right={ <> + {readiness && } {isGrade && (

{t("readiness")}

- +
+ {readiness ? ( + + ) : ( + + )} {showAttempts && ( !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/plan/availability-editor.tsx b/src/components/plan/availability-editor.tsx index bbf5ccd..901c596 100644 --- a/src/components/plan/availability-editor.tsx +++ b/src/components/plan/availability-editor.tsx @@ -5,8 +5,8 @@ import { Loader2, Plus, Trash2 } from "lucide-react" import { useTranslations } from "next-intl" import { toast } from "sonner" import { useRouter } from "@/i18n/navigation" -import { saveAvailability } from "@/app/[locale]/(app)/plan/actions" -import type { PlanAvailability } from "@/db/schema" +import { saveAvailability, saveWeekOverride } from "@/app/[locale]/(app)/plan/actions" +import type { PlanAvailability, WeekOverrides } from "@/db/schema" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Input } from "@/components/ui/input" @@ -21,9 +21,11 @@ type WeeklyRow = { enabled: boolean; from: string; to: string } export function AvailabilityEditor({ semesterId, initial, + weekOverrides, }: { semesterId: string initial: PlanAvailability | null + weekOverrides?: WeekOverrides | null }) { const t = useTranslations("semesterPlan") const router = useRouter() @@ -362,6 +364,8 @@ export function AvailabilityEditor({
+ +
+ + ))} + + )} +
+ 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..3201cf3 --- /dev/null +++ b/src/components/plan/module-plan-prefs.tsx @@ -0,0 +1,203 @@ +"use client" + +import * as React from "react" +import { BookOpen } from "lucide-react" +import { useTranslations } from "next-intl" +import { toast } from "sonner" +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 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) { + toast.error(error instanceof Error ? error.message : String(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} + +
+ {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..2c8ae87 100644 --- a/src/components/plan/module-plan-view.tsx +++ b/src/components/plan/module-plan-view.tsx @@ -20,10 +20,10 @@ import { CalendarClock, Clock, GripVertical, + ListChecks, Loader2, Pencil, Plus, - Sparkles, Trash2, } from "lucide-react" import { useFormatter, useTranslations } from "next-intl" @@ -35,10 +35,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 +48,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 +60,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,19 +77,27 @@ 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 router = useRouter() @@ -114,8 +116,16 @@ export function ModulePlanView({ } } + // 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 +134,7 @@ export function ModulePlanView({ {generating ? ( ) : ( - + )} {t("tasks.generate")} @@ -132,7 +142,21 @@ export function ModulePlanView({ {tasks.length === 0 ? ( -

{t("tasks.empty")}

+ showOutlineNudge ? ( +
+

{t("tasks.outlineNudge")}

+ +
+ ) : ( +

{t("tasks.empty")}

+ ) ) : ( )} @@ -140,7 +164,7 @@ export function ModulePlanView({
- +
@@ -260,6 +284,7 @@ function TaskRow({ {task.title} + {task.aiGenerated && } {task.scheduled && ( @@ -469,142 +494,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..3b7409a 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 { 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,112 +143,6 @@ 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 format = useFormatter() diff --git a/src/components/plan/use-recompute.ts b/src/components/plan/use-recompute.ts new file mode 100644 index 0000000..c800fa2 --- /dev/null +++ b/src/components/plan/use-recompute.ts @@ -0,0 +1,39 @@ +"use client" + +import * as React from "react" +import { useTranslations } from "next-intl" +import { toast } from "sonner" +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 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) { + toast.error(error instanceof Error ? error.message : String(error)) + return [] + } finally { + setComputing(false) + } + }, [semesterId, t, router]) + + return { recompute, computing } +} 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-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/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 +} From 1368b2df471425f2161a8046831ac39c8d30f7ee Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 13:42:25 +0000 Subject: [PATCH 4/9] feat(i18n): stable action error codes + localization sweep (audit R4) - action-errors: typed ERR: layer + toastActionError/useActionErrorToast client mapping (mirrors the AI_ERROR pattern); user-facing throws across plan/goal/writing/thesis/materials/usage actions now emit stable codes, localized client-side (de/en), unknown -> GENERIC - reminders: drop hardcoded de-DE; translatable notification templates via a job-safe createTranslator + reminderLocale() seam (R5 wires per-user locale) - formatters: grade-goal + mini-calendar use the active locale (lib/grades keeps the fixed German grade-scale convention, documented) - wording: drop "BYOK"/"Token-Verbrauch" jargon and the "Embedding-Modell" hint from user-facing strings - mistakes deck: language-independent identity via deck.kind, localized label + "Auto" badge/tooltip; reviewCard now revalidates (comment fixed) - errors coverage test (every code has de+en keys) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012Nrz9bERddhwVXWuZyVBn7 --- messages/de.json | 32 ++++++++++- messages/en.json | 32 ++++++++++- src/app/[locale]/(app)/ai/actions.ts | 3 +- src/app/[locale]/(app)/deck-actions.ts | 7 ++- src/app/[locale]/(app)/learn-actions.ts | 3 +- src/app/[locale]/(app)/materials-actions.ts | 13 +++-- src/app/[locale]/(app)/plan/actions.ts | 3 +- .../[locale]/(app)/plan/plan-task-actions.ts | 3 +- .../[locale]/(app)/plan/schedule-actions.ts | 3 +- src/app/[locale]/(app)/quiz-actions.ts | 11 +++- .../[moduleId]/decks/[deckId]/study/page.tsx | 5 +- .../[programId]/[moduleId]/decks/page.tsx | 1 + .../[locale]/(app)/studies/goal-actions.ts | 3 +- .../[locale]/(app)/studies/writing-actions.ts | 7 ++- src/app/[locale]/(app)/thesis/actions.ts | 7 ++- src/components/action-error-toast.ts | 29 ++++++++++ src/components/learn/deck-card.tsx | 24 +++++++- src/components/learn/goal-card.tsx | 10 ++-- src/components/learn/grade-goal.tsx | 5 +- src/components/learn/mini-calendar.tsx | 14 ++--- .../materials/materials-browser.tsx | 6 +- src/components/plan/availability-editor.tsx | 7 ++- src/components/plan/module-plan-prefs.tsx | 5 +- src/components/plan/module-plan-view.tsx | 13 +++-- src/components/plan/strategy-board.tsx | 5 +- src/components/plan/today-plan-card.tsx | 5 +- src/components/plan/use-recompute.ts | 6 +- .../writing/retry-thesis-button.tsx | 5 +- src/components/writing/thesis-create.tsx | 10 ++-- .../writing/writing-milestone-row.tsx | 7 ++- src/components/writing/writing-workspace.tsx | 4 +- src/lib/action-errors.test.ts | 50 +++++++++++++++++ src/lib/action-errors.ts | 51 +++++++++++++++++ src/lib/ai/usage.ts | 3 +- src/lib/grades.ts | 3 + src/lib/jobs/reminders.ts | 55 ++++++++++++++++--- 36 files changed, 370 insertions(+), 80 deletions(-) create mode 100644 src/components/action-error-toast.ts create mode 100644 src/lib/action-errors.test.ts create mode 100644 src/lib/action-errors.ts diff --git a/messages/de.json b/messages/de.json index 9129231..c5d8001 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", @@ -369,7 +369,7 @@ "saveFailed": "Einstellung konnte nicht gespeichert werden." }, "usage": { - "title": "Dein Token-Verbrauch (30 Tage)", + "title": "Deine KI-Nutzung (30 Tage)", "input": "Input", "output": "Output", "limit": "Limit", @@ -828,7 +828,7 @@ "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.", @@ -975,6 +975,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", @@ -1508,5 +1511,28 @@ "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." + }, + "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.}}" } } diff --git a/messages/en.json b/messages/en.json index ea52894..28b2666 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", @@ -369,7 +369,7 @@ "saveFailed": "The setting could not be saved." }, "usage": { - "title": "Your token usage (30 days)", + "title": "Your AI usage (30 days)", "input": "Input", "output": "Output", "limit": "Limit", @@ -828,7 +828,7 @@ "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.", @@ -975,6 +975,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", @@ -1508,5 +1511,28 @@ "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." + }, + "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.}}" } } 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)/deck-actions.ts b/src/app/[locale]/(app)/deck-actions.ts index aa600c9..26ae100 100644 --- a/src/app/[locale]/(app)/deck-actions.ts +++ b/src/app/[locale]/(app)/deck-actions.ts @@ -7,6 +7,7 @@ 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 { searchChunks, getModuleMaterialSample } from "@/lib/ai/rag" @@ -280,6 +281,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 +307,7 @@ 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") const model = await getLanguageModel(defaultModel, session.user.id) const moduleRow = deckRow.moduleId ? await ownModule(deckRow.moduleId, session.user.id) : null 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)/plan/actions.ts b/src/app/[locale]/(app)/plan/actions.ts index 9f60146..44f5fdb 100644 --- a/src/app/[locale]/(app)/plan/actions.ts +++ b/src/app/[locale]/(app)/plan/actions.ts @@ -5,6 +5,7 @@ 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" @@ -104,7 +105,7 @@ export async function saveWeekOverride( where: eq(semesterPlan.semesterId, semesterId), columns: { id: true, weekOverrides: true }, }) - if (!plan) throw new Error("No availability configured") + if (!plan) actionError("PLAN_NO_AVAILABILITY") const next = { ...(plan.weekOverrides ?? {}) } if (value == null) delete next[week] diff --git a/src/app/[locale]/(app)/plan/plan-task-actions.ts b/src/app/[locale]/(app)/plan/plan-task-actions.ts index bc71a92..7c818fc 100644 --- a/src/app/[locale]/(app)/plan/plan-task-actions.ts +++ b/src/app/[locale]/(app)/plan/plan-task-actions.ts @@ -14,6 +14,7 @@ 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" @@ -45,7 +46,7 @@ async function assertGoalInModule(moduleId: string, goalId: string | null | unde where: eq(moduleGoal.moduleId, moduleId), columns: { id: true }, }) - if (!goals.some((g) => g.id === goalId)) throw new Error("Invalid goal") + if (!goals.some((g) => g.id === goalId)) actionError("PLAN_INVALID_GOAL") } /** Loads a plan task (with its module → semester → program) or throws. */ diff --git a/src/app/[locale]/(app)/plan/schedule-actions.ts b/src/app/[locale]/(app)/plan/schedule-actions.ts index 2770884..1e19a59 100644 --- a/src/app/[locale]/(app)/plan/schedule-actions.ts +++ b/src/app/[locale]/(app)/plan/schedule-actions.ts @@ -6,6 +6,7 @@ import { z } from "zod" import { db } from "@/db" 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" @@ -53,7 +54,7 @@ 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), diff --git a/src/app/[locale]/(app)/quiz-actions.ts b/src/app/[locale]/(app)/quiz-actions.ts index 1ded3a4..e2f921f 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" @@ -503,9 +504,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({ 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..4800f09 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 @@ -64,8 +64,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 ( c.due <= now).length, }} diff --git a/src/app/[locale]/(app)/studies/goal-actions.ts b/src/app/[locale]/(app)/studies/goal-actions.ts index c797004..0f930ed 100644 --- a/src/app/[locale]/(app)/studies/goal-actions.ts +++ b/src/app/[locale]/(app)/studies/goal-actions.ts @@ -5,6 +5,7 @@ import { asc, eq } from "drizzle-orm" import { z } from "zod" import { db } from "@/db" 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" @@ -255,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..b5beed2 100644 --- a/src/app/[locale]/(app)/studies/writing-actions.ts +++ b/src/app/[locale]/(app)/studies/writing-actions.ts @@ -8,6 +8,7 @@ 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" @@ -29,7 +30,7 @@ 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") return { ref: defaultModel, model: await getLanguageModel(defaultModel, userId) } } @@ -96,7 +97,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({ @@ -295,7 +296,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) diff --git a/src/app/[locale]/(app)/thesis/actions.ts b/src/app/[locale]/(app)/thesis/actions.ts index e92d84d..2ad9df4 100644 --- a/src/app/[locale]/(app)/thesis/actions.ts +++ b/src/app/[locale]/(app)/thesis/actions.ts @@ -6,6 +6,7 @@ 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" @@ -23,7 +24,7 @@ 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") return { ref: defaultModel, model: await getLanguageModel(defaultModel, userId) } } @@ -62,7 +63,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,7 +90,7 @@ 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") } } 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/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) const [passed, setPassed] = React.useState(false) + const showError = useActionErrorToast() const [pending, setPending] = React.useState(false) const atMax = attempts.length >= maxAttempts @@ -336,7 +338,7 @@ function GoalAttempts({ setOpen(false) router.refresh() } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)) + showError(error) } finally { setPending(false) } @@ -347,7 +349,7 @@ function GoalAttempts({ await deleteAttempt(id) router.refresh() } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)) + showError(error) } } diff --git a/src/components/learn/grade-goal.tsx b/src/components/learn/grade-goal.tsx index d5a1c40..402ff52 100644 --- a/src/components/learn/grade-goal.tsx +++ b/src/components/learn/grade-goal.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { Target } from "lucide-react" -import { useTranslations } from "next-intl" +import { useLocale, useTranslations } from "next-intl" import { updateGradeGoal } from "@/app/[locale]/(app)/studies/actions" import { requiredGradeForGoal } from "@/lib/grades" import { Input } from "@/components/ui/input" @@ -30,6 +30,7 @@ export function GradeGoal({ initialGoal: string | null }) { const t = useTranslations("dashboard.gradeGoal") + const locale = useLocale() const [target, setTarget] = React.useState(initialGoal ?? "2.0") const saveTimeout = React.useRef | 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/materials/materials-browser.tsx b/src/components/materials/materials-browser.tsx index 56d5fa2..2e2b083 100644 --- a/src/components/materials/materials-browser.tsx +++ b/src/components/materials/materials-browser.tsx @@ -39,6 +39,7 @@ import { } 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 { createFolder, @@ -459,6 +460,7 @@ export function MaterialsBrowser({ indexingEnabled?: boolean }) { const t = useTranslations("materials") + const showError = useActionErrorToast() const router = useRouter() const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })) @@ -499,7 +501,7 @@ export function MaterialsBrowser({ await retryMaterialProcessing(materialId) router.refresh() } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)) + showError(error) } } function changeView(next: "list" | "grid") { @@ -590,7 +592,7 @@ export function MaterialsBrowser({ await action() router.refresh() } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)) + showError(error) } } diff --git a/src/components/plan/availability-editor.tsx b/src/components/plan/availability-editor.tsx index 901c596..f4907d8 100644 --- a/src/components/plan/availability-editor.tsx +++ b/src/components/plan/availability-editor.tsx @@ -4,6 +4,7 @@ import * as React from "react" import { Loader2, Plus, 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 { saveAvailability, saveWeekOverride } from "@/app/[locale]/(app)/plan/actions" import type { PlanAvailability, WeekOverrides } from "@/db/schema" @@ -28,6 +29,7 @@ export function AvailabilityEditor({ weekOverrides?: WeekOverrides | null }) { const t = useTranslations("semesterPlan") + const showError = useActionErrorToast() const router = useRouter() const [pending, setPending] = React.useState<"save" | null>(null) const [weekly, setWeekly] = React.useState>(() => { @@ -60,7 +62,7 @@ export function AvailabilityEditor({ toast.success(t("saved")) router.refresh() } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)) + showError(error) } finally { setPending(null) } @@ -390,6 +392,7 @@ function WeekOverridesSection({ initial: WeekOverrides | null }) { const t = useTranslations("semesterPlan.weekOverride") + const showError = useActionErrorToast() const router = useRouter() const [week, setWeek] = React.useState("") const [hours, setHours] = React.useState("") @@ -403,7 +406,7 @@ function WeekOverridesSection({ await saveWeekOverride(semesterId, isoWeek, value) router.refresh() } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)) + showError(error) } finally { setPending(false) } diff --git a/src/components/plan/module-plan-prefs.tsx b/src/components/plan/module-plan-prefs.tsx index 3201cf3..0e2e424 100644 --- a/src/components/plan/module-plan-prefs.tsx +++ b/src/components/plan/module-plan-prefs.tsx @@ -3,7 +3,7 @@ import * as React from "react" import { BookOpen } 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 { updateModulePlanPrefs } from "@/app/[locale]/(app)/plan/plan-task-actions" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" @@ -42,6 +42,7 @@ export function ModulePlanPrefs({ 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)) @@ -56,7 +57,7 @@ export function ModulePlanPrefs({ await updateModulePlanPrefs(moduleId, patch) router.refresh() } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)) + showError(error) } } diff --git a/src/components/plan/module-plan-view.tsx b/src/components/plan/module-plan-view.tsx index 2c8ae87..6f66e85 100644 --- a/src/components/plan/module-plan-view.tsx +++ b/src/components/plan/module-plan-view.tsx @@ -28,6 +28,7 @@ import { } 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, @@ -100,6 +101,7 @@ export function ModulePlanView({ setupSteps?: SetupStep[] }) { const t = useTranslations("plan") + const showError = useActionErrorToast() const router = useRouter() const [generating, setGenerating] = React.useState(false) @@ -110,7 +112,7 @@ 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) } @@ -182,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 } })) @@ -206,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) } } @@ -254,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) @@ -264,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) } } @@ -435,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) @@ -455,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) } diff --git a/src/components/plan/strategy-board.tsx b/src/components/plan/strategy-board.tsx index 3b7409a..02bf922 100644 --- a/src/components/plan/strategy-board.tsx +++ b/src/components/plan/strategy-board.tsx @@ -3,7 +3,7 @@ import * as React from "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 { toggleSession } from "@/app/[locale]/(app)/plan/schedule-actions" import type { ScheduleWarning } from "@/lib/plan/scheduler" @@ -145,6 +145,7 @@ export function StrategyBoard({ function SessionPreview({ sessions }: { sessions: PreviewSession[] }) { const t = useTranslations("plan") + const showError = useActionErrorToast() const format = useFormatter() const router = useRouter() @@ -159,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..7bd118d 100644 --- a/src/components/plan/today-plan-card.tsx +++ b/src/components/plan/today-plan-card.tsx @@ -2,7 +2,7 @@ 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,6 +22,7 @@ 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() if (items.length === 0) return null @@ -31,7 +32,7 @@ export function TodayPlanCard({ items }: { items: TodayPlanSession[] }) { 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/use-recompute.ts b/src/components/plan/use-recompute.ts index c800fa2..c61b005 100644 --- a/src/components/plan/use-recompute.ts +++ b/src/components/plan/use-recompute.ts @@ -3,6 +3,7 @@ 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" @@ -15,6 +16,7 @@ import type { ScheduleWarning } from "@/lib/plan/scheduler" */ export function useRecompute(semesterId: string) { const t = useTranslations("plan") + const showError = useActionErrorToast() const router = useRouter() const [computing, setComputing] = React.useState(false) @@ -28,12 +30,12 @@ export function useRecompute(semesterId: string) { router.refresh() return warnings } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)) + showError(error) return [] } finally { setComputing(false) } - }, [semesterId, t, router]) + }, [semesterId, t, router, showError]) return { recompute, computing } } 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/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..688b193 --- /dev/null +++ b/src/lib/action-errors.ts @@ -0,0 +1,51 @@ +/** + * 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", + "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/usage.ts b/src/lib/ai/usage.ts index 7326dba..74f84ff 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, @@ -44,6 +45,6 @@ export async function assertWithinLimit(userId: string): Promise { .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") + actionError("LIMIT_EXCEEDED") } } 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/reminders.ts b/src/lib/jobs/reminders.ts index 88197ff..db2dfe1 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, @@ -18,6 +20,32 @@ import { sendPushToUser } from "@/lib/push" import { getAppName } from "@/lib/settings" import { env } from "@/lib/env" +/** + * Resolves the notification locale for a user. This is the single seam R5 will + * wire to `user_prefs.locale` (the column lands in R5's migration 0042); until + * then every user gets the default locale. All reminder formatting below flows + * through the returned locale, so R5 only has to change this function's source. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- R5 wires userId → user_prefs.locale here +async function reminderLocale(userId: string): Promise { + return 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 +134,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 +177,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", }) } @@ -173,9 +210,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: "/", }) } From 9e4bec1337ba04aa4714c6876c11006a43b5e3e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 14:08:36 +0000 Subject: [PATCH 5/9] feat(learning): materials/quiz/deck UX, full-text search, calendar (audit R5) - migration 0042 (additive): material.text_content_tsv (GIN) for full-text search, user_prefs.locale, event.skip_dates - materials: processing/error banner on detail page, ask-document gated on ready, chunk N/M progress, zip-unpack failure surfaced as a failed material - decks: log partial minutes on leave, FSRS next-interval preview on ratings - quizzes: parallel free-text grading with progress, "N cards to mistakes deck" link, honest "not graded" instead of 0%, retry-wrong only when wrong exists, prev/skip navigation, estimated progress for large single-shot generation - dashboard: NextExam scoped to active program, TodayFocus fallback CTA - calendar: delete single recurring occurrence via skip_dates, palette deep-link to a focused event, consistent server-local datetime handling - search: tsvector (GIN) material match + flashcard/question union - notifications: category push toggles reconciled with subscription state - reminders now follow the persisted user locale - tests: recurrence skip-dates, search query builder, FSRS interval preview Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012Nrz9bERddhwVXWuZyVBn7 --- drizzle/0042_search_and_locale.sql | 23 + drizzle/meta/0042_snapshot.json | 6062 +++++++++++++++++ drizzle/meta/_journal.json | 7 + messages/de.json | 27 +- messages/en.json | 27 +- src/app/[locale]/(app)/calendar/actions.ts | 38 +- src/app/[locale]/(app)/calendar/page.tsx | 10 +- .../[locale]/(app)/materials/[id]/page.tsx | 25 +- src/app/[locale]/(app)/page.tsx | 23 +- src/app/[locale]/(app)/quiz-actions.ts | 103 +- src/app/[locale]/(app)/settings/actions.ts | 18 + .../[moduleId]/decks/[deckId]/study/page.tsx | 21 +- .../[programId]/[moduleId]/materials/page.tsx | 2 + .../[moduleId]/quizzes/[quizId]/page.tsx | 20 +- src/app/[locale]/(app)/study/due/page.tsx | 17 +- src/app/api/search/route.ts | 53 +- src/components/calendar/calendar-view.tsx | 67 +- src/components/calendar/event-dialog.tsx | 2 + src/components/layout/command-palette.tsx | 2 +- src/components/layout/locale-switcher.tsx | 4 + src/components/learn/deck-dialogs.tsx | 15 +- src/components/learn/estimated-progress.tsx | 45 + src/components/learn/quiz-dialogs.tsx | 15 +- src/components/learn/quiz-runner.tsx | 67 +- src/components/learn/study-session.tsx | 135 +- src/components/learn/today-focus-card.tsx | 11 +- .../materials/ask-document-button.tsx | 43 +- .../materials/materials-browser.tsx | 11 + .../settings/notification-settings.tsx | 13 +- src/db/schema/materials.ts | 17 +- src/db/schema/studies.ts | 4 + src/db/schema/user-prefs.ts | 3 + src/lib/events/recurrence.test.ts | 27 + src/lib/events/recurrence.ts | 12 +- src/lib/jobs/reminders.ts | 18 +- src/lib/jobs/unpack-zip.ts | 21 + src/lib/learning/fsrs.test.ts | 33 +- src/lib/learning/fsrs.ts | 34 + src/lib/search/query.test.ts | 21 + src/lib/search/query.ts | 15 + 40 files changed, 6956 insertions(+), 155 deletions(-) create mode 100644 drizzle/0042_search_and_locale.sql create mode 100644 drizzle/meta/0042_snapshot.json create mode 100644 src/components/learn/estimated-progress.tsx create mode 100644 src/lib/search/query.test.ts create mode 100644 src/lib/search/query.ts 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/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 4cfd812..8093fc1 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -295,6 +295,13 @@ "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 c5d8001..a4dab40 100644 --- a/messages/de.json +++ b/messages/de.json @@ -366,7 +366,8 @@ "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": "Deine KI-Nutzung (30 Tage)", @@ -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.", @@ -832,7 +839,8 @@ "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", @@ -1043,7 +1051,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": { diff --git a/messages/en.json b/messages/en.json index 28b2666..9ebd69a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -366,7 +366,8 @@ "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 AI usage (30 days)", @@ -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.", @@ -832,7 +839,8 @@ "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", @@ -1043,7 +1051,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": { 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 708834f..49a5f9a 100644 --- a/src/app/[locale]/(app)/calendar/page.tsx +++ b/src/app/[locale]/(app)/calendar/page.tsx @@ -23,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), @@ -95,6 +101,7 @@ export default async function CalendarPage() { ({ id: e.id, title: e.title, @@ -110,6 +117,7 @@ export default async function CalendarPage() { recurrenceUntil: e.recurrenceUntil, recurrenceWeekdays: e.recurrenceWeekdays, recurrenceInterval: e.recurrenceInterval, + skipDates: e.skipDates, }))} planSessions={planSessions.map((s) => ({ id: s.id, 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)/quiz-actions.ts b/src/app/[locale]/(app)/quiz-actions.ts index e2f921f..742f4b9 100644 --- a/src/app/[locale]/(app)/quiz-actions.ts +++ b/src/app/[locale]/(app)/quiz-actions.ts @@ -320,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 @@ -393,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 @@ -443,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 }) @@ -477,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 } } /** @@ -494,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( @@ -528,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)/studies/[programId]/[moduleId]/decks/[deckId]/study/page.tsx b/src/app/[locale]/(app)/studies/[programId]/[moduleId]/decks/[deckId]/study/page.tsx index 4800f09..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": 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]/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)/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/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/calendar/calendar-view.tsx b/src/components/calendar/calendar-view.tsx index b7b52bf..6c1d5d7 100644 --- a/src/components/calendar/calendar-view.tsx +++ b/src/components/calendar/calendar-view.tsx @@ -12,7 +12,7 @@ import { Pencil, 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" @@ -91,6 +91,7 @@ export function CalendarView({ planSessions = [], assignments = [], absences = [], + focusEventId, }: { events: CalendarEvent[] modules: ModuleOption[] @@ -98,6 +99,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 +113,14 @@ 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) // 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 +146,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 +208,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)) @@ -274,6 +309,7 @@ export function CalendarView({ recurrenceUntil: e.recurrenceUntil ?? null, recurrenceWeekdays: e.recurrenceWeekdays ?? null, recurrenceInterval: e.recurrenceInterval ?? null, + skipDates: e.skipDates ?? null, }, from, to @@ -377,6 +413,7 @@ export function CalendarView({
{ - // 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 +489,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/layout/command-palette.tsx b/src/components/layout/command-palette.tsx index 1464ed6..330ba18 100644 --- a/src/components/layout/command-palette.tsx +++ b/src/components/layout/command-palette.tsx @@ -139,7 +139,7 @@ export function CommandPalette() { {results.events.length > 0 && ( {results.events.map((e) => ( - go("/calendar")}> + go(`/calendar?event=${e.id}`)}> {e.title} 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-dialogs.tsx b/src/components/learn/deck-dialogs.tsx index b61808a..c3c0321 100644 --- a/src/components/learn/deck-dialogs.tsx +++ b/src/components/learn/deck-dialogs.tsx @@ -27,6 +27,11 @@ import { startCompleteDeck } 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 cards, a single-shot generation shows an estimated progress + * bar (E14) rather than a bare spinner. */ +const LARGE_GENERATION = 10 /** Controlled edit dialog for a flashcard's front/back (used by row menus). */ export function EditCardDialog({ @@ -228,6 +233,8 @@ export function GenerateCardsDialog({ const [pending, setPending] = React.useState(false) const [complete, setComplete] = React.useState(false) const [jobId, setJobId] = 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 @@ -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 ? ( + ) : (