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 && (
+ {readiness ? (
+
+ ) : (
+ }
+ >
+
+ {tPlan("setupHint")}
+
+ )}
{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")}
+
+ }>
+ {t("planCta")}
+
+
+
+ )}
+
{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({
+
+
)}
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}
-
+
}>
{t("download")}
+ {["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 && (
+ {
+ void deleteOccurrence(ctxMenu.id, ctxMenu.occurrenceDate!)
+ setCtxMenu(null)
+ }}
+ >
+
+ {tEvent("deleteOccurrence")}
+
+ )}
- {tCommon("delete")}
+ {ctxMenu.occurrenceDate ? tEvent("deleteSeries") : tCommon("delete")}
)}
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 ? (
+
) : (
diff --git a/src/components/materials/ask-document-button.tsx b/src/components/materials/ask-document-button.tsx
index 4396129..7e31a0d 100644
--- a/src/components/materials/ask-document-button.tsx
+++ b/src/components/materials/ask-document-button.tsx
@@ -6,14 +6,32 @@ import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { useRouter } from "@/i18n/navigation"
import { Button } from "@/components/ui/button"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
import { createConversation } from "@/app/[locale]/(app)/ai/actions"
-/** Starts a document-scoped AI conversation ("chat with this document"). */
-export function AskDocumentButton({ materialId }: { materialId: string }) {
+/** Starts a document-scoped AI conversation ("chat with this document").
+ * Disabled until extraction has finished — an un-extracted document has no
+ * retrievable text to ground the chat. */
+export function AskDocumentButton({
+ materialId,
+ extractionStatus,
+}: {
+ materialId: string
+ extractionStatus?: string | null
+}) {
const t = useTranslations("materials")
const router = useRouter()
const [pending, setPending] = React.useState(false)
+ // Only files that finished extraction have retrievable text. Links (no
+ // status) stay enabled.
+ const notReady = extractionStatus != null && extractionStatus !== "ready"
+
async function start() {
setPending(true)
try {
@@ -25,10 +43,27 @@ export function AskDocumentButton({ materialId }: { materialId: string }) {
}
}
- return (
- void start()}>
+ const button = (
+ void start()}
+ >
{pending ? : }
{t("askDocument")}
)
+
+ if (!notReady) return button
+
+ return (
+
+
+ {/* A disabled button swallows pointer events, so wrap it for the tooltip. */}
+ }>{button}
+ {t("askDocumentProcessing")}
+
+
+ )
}
diff --git a/src/components/materials/materials-browser.tsx b/src/components/materials/materials-browser.tsx
index 2e2b083..0720c29 100644
--- a/src/components/materials/materials-browser.tsx
+++ b/src/components/materials/materials-browser.tsx
@@ -88,6 +88,9 @@ export type MaterialItem = {
/** AI extraction/embedding pipeline state (files only). */
extractionStatus?: string | null
extractionError?: string | null
+ /** Embedding progress (files only): leaf chunks total / already embedded. */
+ chunksTotal?: number | null
+ chunksEmbedded?: number | null
}
/** Pipeline states that are still moving — used to poll for fresh status. */
@@ -103,10 +106,18 @@ function ProcessingBadge({ item }: { item: MaterialItem }) {
const status = item.extractionStatus
if (item.kind !== "file" || !status || status === "ready") return null
if (ACTIVE_STATUSES.includes(status)) {
+ // For the embedding step, show "N/M" chunk progress when known.
+ const showProgress =
+ status === "embedding" && item.chunksTotal != null && item.chunksTotal > 0
return (
{t(status)}
+ {showProgress && (
+
+ {item.chunksEmbedded ?? 0}/{item.chunksTotal}
+
+ )}
)
}
diff --git a/src/components/settings/notification-settings.tsx b/src/components/settings/notification-settings.tsx
index a7619a2..4179d9e 100644
--- a/src/components/settings/notification-settings.tsx
+++ b/src/components/settings/notification-settings.tsx
@@ -173,13 +173,24 @@ export function NotificationSettings({
aria-label={`${t(`categories.${category}`)} ${t("email")}`}
/>
toggle(category, "push", on)}
aria-label={`${t(`categories.${category}`)} ${t("push")}`}
/>
))}
+ {/* Contextual prompt: push toggles are inert until the browser is
+ subscribed (E23). */}
+ {pushSubscribed === false && (
+
+ {t("pushInactiveHint")}
+
+ )}
{t("emailHint", { email })}
{pushSubscribed === null ? (
diff --git a/src/db/schema/materials.ts b/src/db/schema/materials.ts
index 44cdf19..711b81a 100644
--- a/src/db/schema/materials.ts
+++ b/src/db/schema/materials.ts
@@ -1,6 +1,7 @@
import {
type AnyPgColumn,
bigint,
+ customType,
index,
integer,
jsonb,
@@ -9,10 +10,15 @@ import {
timestamp,
uniqueIndex,
} from "drizzle-orm/pg-core"
-import { relations, sql } from "drizzle-orm"
+import { relations, sql, type SQL } from "drizzle-orm"
import { user } from "./auth"
import { studyModule } from "./studies"
+/** Postgres full-text search vector (generated from `text_content`). */
+const tsvector = customType<{ data: string }>({
+ dataType: () => "tsvector",
+})
+
export type MaterialKind = "file" | "link"
/**
@@ -119,6 +125,14 @@ export const material = pgTable(
chunksTotal: integer("chunks_total"),
/** Number of leaf chunks already embedded (for resumable/progress display). */
chunksEmbedded: integer("chunks_embedded"),
+ /** Full-text search vector over `text_content`, for the command palette
+ * search (replaces the ILIKE seq-scan). Uses the 'german' config to match
+ * the query's `websearch_to_tsquery('german', …)` in the search route.
+ * `coalesce` guards the nullable column so the generated expression stays
+ * total. Mirrors material_chunk.contentTsv. */
+ textContentTsv: tsvector("text_content_tsv").generatedAlwaysAs(
+ (): SQL => sql`to_tsvector('german', coalesce(${material.textContent}, ''))`
+ ),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
@@ -130,6 +144,7 @@ export const material = pgTable(
index("material_moduleId_idx").on(t.moduleId),
index("material_folderId_idx").on(t.folderId),
index("material_contentHash_idx").on(t.userId, t.contentHash),
+ index("material_text_content_tsv_idx").using("gin", t.textContentTsv),
]
)
diff --git a/src/db/schema/studies.ts b/src/db/schema/studies.ts
index 81517c4..25e8dae 100644
--- a/src/db/schema/studies.ts
+++ b/src/db/schema/studies.ts
@@ -293,6 +293,10 @@ export const studyEvent = pgTable(
recurrenceWeekdays: jsonb("recurrence_weekdays").$type
(),
/** For "custom": repeat every N weeks (1–4), anchored at startsAt's week. */
recurrenceInterval: integer("recurrence_interval"),
+ /** ISO dates (yyyy-mm-dd) of recurring occurrences deleted individually.
+ * `expandOccurrences` skips these — lets a single instance be removed
+ * without deleting the whole series. */
+ skipDates: jsonb("skip_dates").$type(),
...timestamps,
},
(t) => [
diff --git a/src/db/schema/user-prefs.ts b/src/db/schema/user-prefs.ts
index f234246..9090905 100644
--- a/src/db/schema/user-prefs.ts
+++ b/src/db/schema/user-prefs.ts
@@ -20,5 +20,8 @@ export const userPrefs = pgTable("user_prefs", {
preferredModel: text("preferred_model"),
/** Weekly study-time goal in minutes; null = no goal set. */
weeklyGoalMinutes: integer("weekly_goal_minutes"),
+ /** Preferred UI/notification locale (e.g. "de", "en"); null = app default.
+ * Read by the reminder job to localize push/email. */
+ locale: text("locale"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
})
diff --git a/src/lib/events/recurrence.test.ts b/src/lib/events/recurrence.test.ts
index 2dbf51a..10f6013 100644
--- a/src/lib/events/recurrence.test.ts
+++ b/src/lib/events/recurrence.test.ts
@@ -71,6 +71,33 @@ describe("expandOccurrences", () => {
expect(occ).toHaveLength(1)
expect(occ[0].endsAt!.getTime() - occ[0].startsAt.getTime()).toBe(90 * 60 * 1000)
})
+
+ it("skips individually-deleted occurrences via skipDates (E18)", () => {
+ const occ = expandOccurrences(
+ { ...base, recurrence: "weekly", skipDates: ["2026-04-20", "2026-05-04"] },
+ new Date("2026-04-01"),
+ new Date("2026-05-05")
+ )
+ expect(occ.map((o) => o.occurrenceDate)).toEqual(["2026-04-13", "2026-04-27"])
+ })
+
+ it("leaves the series intact when skipDates is empty or null", () => {
+ const empty = expandOccurrences(
+ { ...base, recurrence: "weekly", skipDates: [] },
+ new Date("2026-04-01"),
+ new Date("2026-04-28")
+ )
+ expect(empty.map((o) => o.occurrenceDate)).toEqual(["2026-04-13", "2026-04-20", "2026-04-27"])
+ })
+
+ it("can skip the first (non-instance) occurrence too", () => {
+ const occ = expandOccurrences(
+ { ...base, recurrence: "weekly", skipDates: ["2026-04-13"] },
+ new Date("2026-04-01"),
+ new Date("2026-04-28")
+ )
+ expect(occ.map((o) => o.occurrenceDate)).toEqual(["2026-04-20", "2026-04-27"])
+ })
})
describe("custom recurrence", () => {
diff --git a/src/lib/events/recurrence.ts b/src/lib/events/recurrence.ts
index 7eb434e..6df1a06 100644
--- a/src/lib/events/recurrence.ts
+++ b/src/lib/events/recurrence.ts
@@ -9,6 +9,8 @@ export type RecurringEventLike = {
recurrenceWeekdays?: number[] | null
/** "custom" only: repeat every N weeks, anchored at startsAt's week. */
recurrenceInterval?: number | null
+ /** ISO dates (local) of individually-deleted occurrences to skip (E18). */
+ skipDates?: string[] | null
}
export type Occurrence = {
@@ -40,21 +42,25 @@ export function expandOccurrences(
): Occurrence[] {
const duration = event.endsAt ? event.endsAt.getTime() - event.startsAt.getTime() : null
+ // Occurrences whose local date was individually deleted (E18) are skipped so
+ // the rest of the series survives.
+ const skip = event.skipDates?.length ? new Set(event.skipDates) : null
const make = (startsAt: Date, isInstance: boolean): Occurrence => ({
startsAt,
endsAt: duration != null ? new Date(startsAt.getTime() + duration) : null,
occurrenceDate: toIsoDate(startsAt),
isRecurrenceInstance: isInstance,
})
+ const notSkipped = (o: Occurrence) => !skip?.has(o.occurrenceDate)
if (event.recurrence === "none" || !event.recurrence) {
return event.startsAt <= to && (event.endsAt ?? event.startsAt) >= from
- ? [make(event.startsAt, false)]
+ ? [make(event.startsAt, false)].filter(notSkipped)
: []
}
if (event.recurrence === "custom") {
- return expandCustom(event, from, to, make)
+ return expandCustom(event, from, to, make).filter(notSkipped)
}
const stepWeeks = event.recurrence === "biweekly" ? 2 : 1
@@ -80,7 +86,7 @@ export function expandOccurrences(
}
cursor = addWeeks(cursor, stepWeeks)
}
- return out
+ return out.filter(notSkipped)
}
/**
diff --git a/src/lib/jobs/reminders.ts b/src/lib/jobs/reminders.ts
index db2dfe1..f3e33de 100644
--- a/src/lib/jobs/reminders.ts
+++ b/src/lib/jobs/reminders.ts
@@ -12,6 +12,7 @@ import {
semesterPlan,
studyEvent,
user,
+ userPrefs,
type NotificationChannels,
} from "@/db/schema"
import { sendEmail } from "@/lib/email"
@@ -21,14 +22,19 @@ 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.
+ * Resolves the notification locale for a user from `user_prefs.locale`
+ * (migration 0042), falling back to the app default when unset or unsupported.
+ * All reminder formatting flows through the returned locale.
*/
-// 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
+ const prefs = await db.query.userPrefs.findFirst({
+ where: eq(userPrefs.userId, userId),
+ columns: { locale: true },
+ })
+ const locale = prefs?.locale
+ return locale && routing.locales.includes(locale as (typeof routing.locales)[number])
+ ? locale
+ : routing.defaultLocale
}
type NotificationsT = (key: string, values?: Record) => string
diff --git a/src/lib/jobs/unpack-zip.ts b/src/lib/jobs/unpack-zip.ts
index 45b7ee6..fd73e9b 100644
--- a/src/lib/jobs/unpack-zip.ts
+++ b/src/lib/jobs/unpack-zip.ts
@@ -90,6 +90,27 @@ export async function unpackZip(payload: UnpackZipPayload): Promise {
console.error("[unpack-zip] failed to enqueue embedding", error)
}
}
+ } catch (error) {
+ // The archive isn't kept as its own material, so a failed unpack would
+ // otherwise vanish silently (retryLimit 0). Record a failed material row
+ // for the archive so the materials list badge + detail banner (E1) surface
+ // the reason to the user.
+ const message = error instanceof Error ? error.message : String(error)
+ console.error(`[unpack-zip] failed to unpack ${zipName}`, error)
+ try {
+ await db.insert(material).values({
+ userId,
+ moduleId,
+ kind: "file",
+ name: zipName,
+ mimeType: "application/zip",
+ folderId: parentFolderId,
+ extractionStatus: "failed",
+ extractionError: `Could not unpack archive: ${message}`,
+ })
+ } catch (insertError) {
+ console.error("[unpack-zip] failed to record unpack error", insertError)
+ }
} finally {
// Discard the temporary archive regardless of outcome.
await deleteFile(zipStoragePath)
diff --git a/src/lib/learning/fsrs.test.ts b/src/lib/learning/fsrs.test.ts
index 77ff15c..c173632 100644
--- a/src/lib/learning/fsrs.test.ts
+++ b/src/lib/learning/fsrs.test.ts
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest"
-import { scheduleReview, type CardSchedulingFields } from "./fsrs"
+import {
+ formatReviewInterval,
+ previewIntervals,
+ scheduleReview,
+ type CardSchedulingFields,
+} from "./fsrs"
const newCard = (): CardSchedulingFields => ({
due: new Date("2026-07-01T10:00:00Z"),
@@ -38,3 +43,29 @@ describe("scheduleReview", () => {
expect(second.reps).toBe(2)
})
})
+
+describe("previewIntervals", () => {
+ it("returns a monotonic due date per rating without mutating the card", () => {
+ const now = new Date("2026-07-01T10:00:00Z")
+ const p = previewIntervals(newCard(), now)
+ // Again ≤ Hard ≤ Good ≤ Easy in next-due distance.
+ expect(p[1].getTime()).toBeLessThanOrEqual(p[2].getTime())
+ expect(p[2].getTime()).toBeLessThanOrEqual(p[3].getTime())
+ expect(p[3].getTime()).toBeLessThanOrEqual(p[4].getTime())
+ // Matches the real schedule for a chosen rating.
+ expect(p[3].getTime()).toBe(scheduleReview(newCard(), 3, now).due.getTime())
+ })
+})
+
+describe("formatReviewInterval", () => {
+ const from = new Date("2026-07-01T10:00:00Z")
+ it("formats minutes, hours, days and months", () => {
+ expect(formatReviewInterval(from, new Date("2026-07-01T10:10:00Z"))).toBe("10m")
+ expect(formatReviewInterval(from, new Date("2026-07-01T13:00:00Z"))).toBe("3h")
+ expect(formatReviewInterval(from, new Date("2026-07-05T10:00:00Z"))).toBe("4d")
+ expect(formatReviewInterval(from, new Date("2026-09-01T10:00:00Z"))).toBe("2mo")
+ })
+ it("clamps sub-minute gaps", () => {
+ expect(formatReviewInterval(from, new Date("2026-07-01T10:00:20Z"))).toBe("<1m")
+ })
+})
diff --git a/src/lib/learning/fsrs.ts b/src/lib/learning/fsrs.ts
index 4e0c8b6..2fd5e93 100644
--- a/src/lib/learning/fsrs.ts
+++ b/src/lib/learning/fsrs.ts
@@ -54,3 +54,37 @@ export function scheduleReview(
lastReview: card.last_review ?? null,
}
}
+
+/**
+ * Next-due date for each of the four ratings, without mutating the card —
+ * powers the interval preview on the rating buttons (E8). Pure: no DB, no
+ * server round-trip.
+ */
+export function previewIntervals(
+ fields: CardSchedulingFields,
+ now: Date = new Date()
+): Record {
+ const card = toFsrsCard(fields)
+ const out = {} as Record
+ for (const rating of [1, 2, 3, 4] as const) {
+ out[rating] = scheduler.next(card, now, rating as Grade).card.due
+ }
+ return out
+}
+
+/**
+ * Compact human label for the gap between `from` and `due` ("<1m", "10m",
+ * "3h", "4d", "2mo"). Rounds to the coarsest natural unit so the preview stays
+ * glanceable. Pure — see fsrs.test.ts.
+ */
+export function formatReviewInterval(from: Date, due: Date): string {
+ const mins = Math.round((due.getTime() - from.getTime()) / 60000)
+ if (mins < 1) return "<1m"
+ if (mins < 60) return `${mins}m`
+ const hours = Math.round(mins / 60)
+ if (hours < 24) return `${hours}h`
+ const days = Math.round(hours / 24)
+ if (days < 30) return `${days}d`
+ const months = Math.round(days / 30)
+ return `${months}mo`
+}
diff --git a/src/lib/search/query.test.ts b/src/lib/search/query.test.ts
new file mode 100644
index 0000000..737388f
--- /dev/null
+++ b/src/lib/search/query.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from "vitest"
+import { escapeLike, likePattern } from "./query"
+
+describe("escapeLike", () => {
+ it("escapes LIKE wildcards so user input matches literally", () => {
+ expect(escapeLike("50%")).toBe("50\\%")
+ expect(escapeLike("a_b")).toBe("a\\_b")
+ expect(escapeLike("c:\\path")).toBe("c:\\\\path")
+ })
+
+ it("leaves ordinary text untouched", () => {
+ expect(escapeLike("Analysis")).toBe("Analysis")
+ })
+})
+
+describe("likePattern", () => {
+ it("wraps the escaped term in wildcards", () => {
+ expect(likePattern("foo")).toBe("%foo%")
+ expect(likePattern("100%")).toBe("%100\\%%")
+ })
+})
diff --git a/src/lib/search/query.ts b/src/lib/search/query.ts
new file mode 100644
index 0000000..d400ea0
--- /dev/null
+++ b/src/lib/search/query.ts
@@ -0,0 +1,15 @@
+/**
+ * Pure helpers for the command-palette search route. Kept DB-free so the
+ * escaping rules are unit-tested (see query.test.ts).
+ */
+
+/** Escapes the LIKE/ILIKE wildcard metacharacters so user input matches
+ * literally (backslash is the default ESCAPE char in Postgres LIKE). */
+export function escapeLike(input: string): string {
+ return input.replace(/[\\%_]/g, "\\$&")
+}
+
+/** Builds a `%…%` ILIKE pattern with wildcards in the term escaped. */
+export function likePattern(input: string): string {
+ return `%${escapeLike(input)}%`
+}
From cc7699e24b61d51a80a26ffc298623ffb4041d93 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sun, 19 Jul 2026 14:31:03 +0000
Subject: [PATCH 6/9] feat(infra): concurrency, headers, caching and cost
hardening (audit R6)
- writing project + retryThesis + createModule: transactional / onConflict so
concurrent get-or-create can't violate the partial unique indexes or leave
goalless modules
- first-admin promotion moved to a post-commit hook under a pg advisory lock
- material file route returns 416 for unsatisfiable ranges
- cascade delete of program/semester/module enqueues a sweep-orphan-files job
to remove the underlying storage objects (crash-safe, chunked)
- cron schedules honor CRON_TZ; db pool gets explicit max/idle/connect timeouts
and prepare:false for a transaction pooler
- background AI paths respect the usage cap (embedding/OCR skip, search/title
degrade gracefully)
- getSetting gets a 30s in-process TTL cache (invalidated on write)
- enforcing Content-Security-Policy (KaTeX/pdf.js/AI-safe) + HSTS
- chat route rejects oversized bodies (413) before buffering
- new ttl-cache + storage-paths helpers with unit tests
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_012Nrz9bERddhwVXWuZyVBn7
---
next.config.ts | 52 +++++++++++
src/app/[locale]/(app)/studies/actions.ts | 33 +++++--
.../[locale]/(app)/studies/writing-actions.ts | 41 +++++++-
src/app/[locale]/(app)/thesis/actions.ts | 54 +++++++----
src/app/api/ai/chat/route.ts | 16 +++-
src/app/api/materials/[id]/file/route.ts | 10 ++
src/db/index.ts | 24 ++++-
src/lib/ai/rag.ts | 93 ++++++++++++-------
src/lib/ai/usage.ts | 42 ++++++---
src/lib/auth/index.ts | 33 ++++++-
src/lib/jobs/index.ts | 54 ++++++++++-
src/lib/settings.ts | 31 +++++++
src/lib/studies/orphan-cleanup.test.ts | 27 ++++++
src/lib/studies/orphan-cleanup.ts | 67 +++++++++++++
src/lib/studies/storage-paths.ts | 16 ++++
src/lib/ttl-cache.test.ts | 49 ++++++++++
src/lib/ttl-cache.ts | 39 ++++++++
17 files changed, 597 insertions(+), 84 deletions(-)
create mode 100644 src/lib/studies/orphan-cleanup.test.ts
create mode 100644 src/lib/studies/orphan-cleanup.ts
create mode 100644 src/lib/studies/storage-paths.ts
create mode 100644 src/lib/ttl-cache.test.ts
create mode 100644 src/lib/ttl-cache.ts
diff --git a/next.config.ts b/next.config.ts
index 434917f..e716bfe 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,6 +1,53 @@
import type { NextConfig } from "next"
import createNextIntlPlugin from "next-intl/plugin"
+const isDev = process.env.NODE_ENV !== "production"
+
+/**
+ * Content-Security-Policy for the app. Enforcing (not report-only). Reasoning
+ * per directive — the app renders user Markdown/LaTeX (KaTeX), views PDFs via
+ * pdf.js (web worker), uploads via tus, and streams AI through our own routes:
+ *
+ * - default-src 'self' everything falls back to same-origin.
+ * - script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'
+ * Next.js injects inline bootstrap/hydration scripts and we don't run a
+ * nonce middleware, so 'unsafe-inline' is required for the app to boot.
+ * 'wasm-unsafe-eval' covers pdf.js' optional WASM image decoders without
+ * opening full eval. In dev, React Fast Refresh needs 'unsafe-eval'.
+ * - style-src 'self' 'unsafe-inline'
+ * KaTeX and Tailwind's runtime inject inline styles; there is no nonce.
+ * - img-src 'self' data: blob: thumbnails, KaTeX/canvas data URIs, object URLs.
+ * - font-src 'self' data: KaTeX ships fonts, some inlined as data URIs.
+ * - worker-src 'self' blob: pdf.js loads its worker from a blob URL.
+ * - connect-src 'self' chat/AI stream through our own API routes, not
+ * the browser → no provider origins needed. tus uploads hit same-origin.
+ * - media-src 'self' blob: audio/video preview from object URLs.
+ * - object-src 'none' no
{assignments.length === 0 ? (
- {t("empty")}
+
) : (
{assignments.map((a) => {
diff --git a/src/app/[locale]/(app)/studies/[programId]/[moduleId]/chat/page.tsx b/src/app/[locale]/(app)/studies/[programId]/[moduleId]/chat/page.tsx
index 933b1e6..867e39e 100644
--- a/src/app/[locale]/(app)/studies/[programId]/[moduleId]/chat/page.tsx
+++ b/src/app/[locale]/(app)/studies/[programId]/[moduleId]/chat/page.tsx
@@ -11,6 +11,7 @@ import { deleteConversation } from "@/app/[locale]/(app)/ai/actions"
import { NewChatButton } from "@/components/ai/new-chat-button"
import { DeleteButton } from "@/components/studies/delete-button"
import { Card, CardContent } from "@/components/ui/card"
+import { EmptyState } from "@/components/ui/empty-state"
export default async function ModuleChatPage({
params,
@@ -59,11 +60,7 @@ export default async function ModuleChatPage({
) : conversations.length === 0 ? (
-
-
- {t("noConversations")}
-
-
+
) : (
{conversations.map((c) => (
diff --git a/src/app/[locale]/(app)/studies/[programId]/[moduleId]/decks/page.tsx b/src/app/[locale]/(app)/studies/[programId]/[moduleId]/decks/page.tsx
index 050f5f0..00a6e30 100644
--- a/src/app/[locale]/(app)/studies/[programId]/[moduleId]/decks/page.tsx
+++ b/src/app/[locale]/(app)/studies/[programId]/[moduleId]/decks/page.tsx
@@ -8,6 +8,8 @@ import { ownModule } from "@/lib/studies/access"
import { getModuleColorClasses, getModuleIcon } from "@/lib/module-visuals"
import { DeckCard } from "@/components/learn/deck-card"
import { DeckDialog } from "@/components/learn/deck-dialogs"
+import { EmptyState } from "@/components/ui/empty-state"
+import { Layers } from "lucide-react"
export default async function ModuleDecksPage({
params,
@@ -39,7 +41,7 @@ export default async function ModuleDecksPage({
/>
{decks.length === 0 ? (
- {t("empty")}
+
) : (
{decks.map((d) => (
diff --git a/src/app/[locale]/(app)/studies/[programId]/[moduleId]/quizzes/page.tsx b/src/app/[locale]/(app)/studies/[programId]/[moduleId]/quizzes/page.tsx
index 45c0f09..97c1579 100644
--- a/src/app/[locale]/(app)/studies/[programId]/[moduleId]/quizzes/page.tsx
+++ b/src/app/[locale]/(app)/studies/[programId]/[moduleId]/quizzes/page.tsx
@@ -9,6 +9,8 @@ import { ownModule } from "@/lib/studies/access"
import { getModuleColorClasses, getModuleIcon } from "@/lib/module-visuals"
import { QuizCard } from "@/components/learn/quiz-card"
import { GenerateQuizDialog, QuizDialog } from "@/components/learn/quiz-dialogs"
+import { EmptyState } from "@/components/ui/empty-state"
+import { ListChecks } from "lucide-react"
export default async function ModuleQuizzesPage({
params,
@@ -45,7 +47,7 @@ export default async function ModuleQuizzesPage({
/>
{quizzes.length === 0 ? (
- {t("empty")}
+
) : (
{quizzes.map((q) => {
diff --git a/src/components/ai/chat-dock.tsx b/src/components/ai/chat-dock.tsx
index 1a8d978..c0d119a 100644
--- a/src/components/ai/chat-dock.tsx
+++ b/src/components/ai/chat-dock.tsx
@@ -1,18 +1,23 @@
"use client"
import * as React from "react"
+import dynamic from "next/dynamic"
import { MessageCircle, X } from "lucide-react"
import { useTranslations } from "next-intl"
import { usePathname } from "@/i18n/navigation"
-import {
- CHAT_OPEN_EVENT,
- ConversationPanel,
- LAST_CHAT_KEY,
-} from "@/components/ai/conversation-panel"
+import { CHAT_OPEN_EVENT, LAST_CHAT_KEY } from "@/components/ai/chat-events"
import type { ModuleOption } from "@/components/learn/module-select"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
+// The conversation panel drags in the AI SDK, react-markdown and katex. Load it
+// on demand (client-only) so none of that lands in the shared layout bundle —
+// it is fetched the first time the dock actually opens (F1).
+const ConversationPanel = dynamic(
+ () => import("@/components/ai/conversation-panel").then((m) => m.ConversationPanel),
+ { ssr: false }
+)
+
export { CHAT_OPEN_EVENT }
/** Floating chat launcher + dock panel (conversation UI lives in ConversationPanel). */
diff --git a/src/components/ai/chat-events.ts b/src/components/ai/chat-events.ts
new file mode 100644
index 0000000..eb15e98
--- /dev/null
+++ b/src/components/ai/chat-events.ts
@@ -0,0 +1,8 @@
+/**
+ * Lightweight chat dock constants. Kept in their own module (no React / AI-SDK
+ * imports) so the always-mounted `ChatDock` and `BottomNav` can reference them
+ * without pulling the heavy `ConversationPanel` (AI SDK + markdown + katex) into
+ * the shared layout bundle — that panel is loaded via `next/dynamic` on open.
+ */
+export const LAST_CHAT_KEY = "studyhelper.lastChatId"
+export const CHAT_OPEN_EVENT = "studyhelper:chat-open"
diff --git a/src/components/ai/conversation-panel.tsx b/src/components/ai/conversation-panel.tsx
index dbef83b..b6618e8 100644
--- a/src/components/ai/conversation-panel.tsx
+++ b/src/components/ai/conversation-panel.tsx
@@ -38,9 +38,7 @@ import {
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { ConfirmDeleteDialog } from "@/components/studies/confirm-delete-dialog"
-
-export const LAST_CHAT_KEY = "studyhelper.lastChatId"
-export const CHAT_OPEN_EVENT = "studyhelper:chat-open"
+import { CHAT_OPEN_EVENT, LAST_CHAT_KEY } from "@/components/ai/chat-events"
export type ConversationMeta = {
id: string
diff --git a/src/components/calendar/calendar-view-dynamic.tsx b/src/components/calendar/calendar-view-dynamic.tsx
new file mode 100644
index 0000000..2c7bfea
--- /dev/null
+++ b/src/components/calendar/calendar-view-dynamic.tsx
@@ -0,0 +1,35 @@
+"use client"
+
+import * as React from "react"
+import dynamic from "next/dynamic"
+import type { CalendarView as CalendarViewImpl } from "@/components/calendar/calendar-view"
+import { Card, CardContent } from "@/components/ui/card"
+import { Skeleton } from "@/components/ui/skeleton"
+
+function CalendarSkeleton() {
+ return (
+
+
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+ ))}
+
+
+
+
+ )
+}
+
+// FullCalendar (@fullcalendar/react + daygrid/timegrid/interaction) is a heavy
+// grid library. Load it client-only and off the critical path so the page shell
+// paints immediately behind a skeleton (F2). SSR is disabled because the grid
+// relies on layout measurement in the browser.
+const CalendarViewLazy = dynamic(
+ () => import("@/components/calendar/calendar-view").then((m) => m.CalendarView),
+ { ssr: false, loading: () =>
}
+)
+
+export function CalendarView(props: React.ComponentProps
) {
+ return
+}
diff --git a/src/components/calendar/calendar-view.tsx b/src/components/calendar/calendar-view.tsx
index 6c1d5d7..6f0f939 100644
--- a/src/components/calendar/calendar-view.tsx
+++ b/src/components/calendar/calendar-view.tsx
@@ -4,6 +4,7 @@ import * as React from "react"
import FullCalendar from "@fullcalendar/react"
import dayGridPlugin from "@fullcalendar/daygrid"
import timeGridPlugin from "@fullcalendar/timegrid"
+import listPlugin from "@fullcalendar/list"
import interactionPlugin from "@fullcalendar/interaction"
import deLocale from "@fullcalendar/core/locales/de"
import type { EventClickArg, EventDropArg, EventInput } from "@fullcalendar/core"
@@ -122,6 +123,14 @@ export function CalendarView({
} | null>(null)
const calendarRef = React.useRef(null)
+ // Default phones to the agenda (listWeek); larger screens keep the month grid
+ // (E21). Only the initial view is derived — the toolbar lets users switch.
+ const [initialView] = React.useState(() =>
+ typeof window !== "undefined" && window.matchMedia("(max-width: 640px)").matches
+ ? "listWeek"
+ : "dayGridMonth"
+ )
+
// Close the right-click menu on outside interaction or Escape. Other keys
// (Tab/Enter/arrows) must keep working so the menu is keyboard-operable.
React.useEffect(() => {
@@ -414,12 +423,13 @@ export function CalendarView({
["variant"]
+ triggerSize?: React.ComponentProps["size"]
open?: boolean
onOpenChange?: (open: boolean) => void
+ /** Cap the dialog height and scroll its body — for tall forms (F16). */
+ scrollable?: boolean
+ /** Extra classes on the dialog content (e.g. a wider `sm:max-w-lg`). */
+ contentClassName?: string
}) {
const tCommon = useTranslations("common")
+ const showError = useActionErrorToast()
const [internalOpen, setInternalOpen] = React.useState(false)
const [pending, setPending] = React.useState(false)
@@ -62,7 +72,7 @@ export function FormDialog({
await onSubmit(form)
setOpen(false)
} catch (error) {
- toast.error(error instanceof Error ? error.message : String(error))
+ showError(error)
} finally {
setPending(false)
}
@@ -71,9 +81,13 @@ export function FormDialog({
return (
)
}
diff --git a/src/components/studies/module-dialog.tsx b/src/components/studies/module-dialog.tsx
index 45c7a21..eb636e0 100644
--- a/src/components/studies/module-dialog.tsx
+++ b/src/components/studies/module-dialog.tsx
@@ -1,18 +1,11 @@
"use client"
import * as React from "react"
-import { Loader2, Pencil, Plus } from "lucide-react"
+import { Pencil, Plus } from "lucide-react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { useRouter } from "@/i18n/navigation"
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog"
+import { FormDialog } from "@/components/form-dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
@@ -74,16 +67,8 @@ export function ModuleDialog({
const t = useTranslations("studies")
const tDialog = useTranslations("studies.moduleDialog")
const tGoals = useTranslations("goals")
- const tCommon = useTranslations("common")
const router = useRouter()
- const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)
const controlled = controlledOpen !== undefined
- const open = controlled ? controlledOpen : uncontrolledOpen
- const setOpen = (v: boolean) => {
- if (controlled) onOpenChange?.(v)
- else setUncontrolledOpen(v)
- }
- const [pending, setPending] = React.useState(false)
const [status, setStatus] = React.useState(module?.status ?? "planned")
const [icon, setIcon] = React.useState(module?.icon ?? null)
const [color, setColor] = React.useState(module?.color ?? null)
@@ -97,9 +82,7 @@ export function ModuleDialog({
failed: t("module.statusFailed"),
}
- async function onSubmit(e: React.FormEvent) {
- e.preventDefault()
- const form = new FormData(e.currentTarget)
+ async function onSubmit(form: FormData) {
const numOrNull = (key: string) =>
form.get(key) !== null && String(form.get(key)).trim() !== ""
? Number(form.get(key))
@@ -113,45 +96,35 @@ export function ModuleDialog({
icon,
color,
}
- setPending(true)
- try {
- if (isEdit) await updateModule(module!.id!, payload)
- else await createModule(semesterId, { ...payload, goalTypes })
- toast.success(isEdit ? t("updated") : t("created"))
- setOpen(false)
- router.refresh()
- } catch (error) {
- toast.error(error instanceof Error ? error.message : String(error))
- } finally {
- setPending(false)
- }
+ if (isEdit) await updateModule(module!.id!, payload)
+ else await createModule(semesterId, { ...payload, goalTypes })
+ toast.success(isEdit ? t("updated") : t("created"))
+ router.refresh()
}
const previewColor = getModuleColorClasses(color)
return (
-
- {!controlled && (
- :
- }
- >
- {isEdit ? (
-
- ) : (
- <>
-
- {t("newModule")}
- >
- )}
-
- )}
-
-
- {isEdit ? t("editModule") : t("newModule")}
-
-
-
-
+
)
}
diff --git a/src/components/ui/empty-state.tsx b/src/components/ui/empty-state.tsx
new file mode 100644
index 0000000..c1719ee
--- /dev/null
+++ b/src/components/ui/empty-state.tsx
@@ -0,0 +1,41 @@
+import type { LucideIcon } from "lucide-react"
+import { cn } from "@/lib/utils"
+
+/**
+ * Shared empty-state: an optional icon, a title, an optional description and an
+ * optional action (button / link). Keeps the app's "nothing here yet" surfaces
+ * visually consistent instead of ad-hoc bare `` tags (F15).
+ */
+export function EmptyState({
+ icon: Icon,
+ title,
+ description,
+ action,
+ className,
+}: {
+ icon?: LucideIcon
+ title: string
+ description?: string
+ action?: React.ReactNode
+ className?: string
+}) {
+ return (
+
+ {Icon && (
+
+
+
+ )}
+
{title}
+ {description && (
+
{description}
+ )}
+ {action &&
{action}
}
+
+ )
+}
From c42940c72ea5fc5bf7d7f123c1a2e80d8b81f1bf Mon Sep 17 00:00:00 2001
From: Claude
Date: Sun, 19 Jul 2026 16:49:27 +0000
Subject: [PATCH 8/9] perf(frontend): mobile tab slot, stacked prefs,
optimistic toggles (audit R7 part 2)
- module tabs: scrollable tab strip with a pinned, non-scrolling "more tools"
button so it stays reachable on phones (D5)
- module-plan prefs row stacks its fields under sm (D4)
- session dialog + today-plan card disable toggles while a mutation is in
flight (useTransition) to prevent double-fire; localized errors (D13)
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_012Nrz9bERddhwVXWuZyVBn7
---
src/components/calendar/session-dialog.tsx | 30 +++++++-----
src/components/plan/module-plan-prefs.tsx | 3 +-
src/components/plan/today-plan-card.tsx | 22 +++++----
src/components/studies/module-tabs.tsx | 56 ++++++++++++----------
4 files changed, 64 insertions(+), 47 deletions(-)
diff --git a/src/components/calendar/session-dialog.tsx b/src/components/calendar/session-dialog.tsx
index e96cb41..d9c1abe 100644
--- a/src/components/calendar/session-dialog.tsx
+++ b/src/components/calendar/session-dialog.tsx
@@ -2,7 +2,7 @@
import * as React from "react"
import { useFormatter, useTranslations } from "next-intl"
-import { toast } from "sonner"
+import { useActionErrorToast } from "@/components/action-error-toast"
import { useRouter } from "@/i18n/navigation"
import { togglePlanTask } from "@/app/[locale]/(app)/plan/plan-task-actions"
import { toggleSession } from "@/app/[locale]/(app)/plan/schedule-actions"
@@ -33,16 +33,22 @@ export function SessionDialog({
const t = useTranslations("plan.session")
const format = useFormatter()
const router = useRouter()
+ const showError = useActionErrorToast()
+ const [pending, startTransition] = React.useTransition()
if (!session) return null
- async function run(fn: () => Promise) {
- try {
- await fn()
- router.refresh()
- } catch (error) {
- toast.error(error instanceof Error ? error.message : String(error))
- }
+ function run(fn: () => Promise) {
+ // Disable the controls while a toggle is in flight so rapid taps can't
+ // double-fire against the server (D13).
+ startTransition(async () => {
+ try {
+ await fn()
+ router.refresh()
+ } catch (error) {
+ showError(error)
+ }
+ })
}
return (
@@ -64,7 +70,8 @@ export function SessionDialog({
@@ -82,9 +89,8 @@ export function SessionDialog({
>
- void run(() => togglePlanTask(task.id, Boolean(on)))
- }
+ disabled={pending}
+ onCheckedChange={(on) => run(() => togglePlanTask(task.id, Boolean(on)))}
/>
{task.title}
diff --git a/src/components/plan/module-plan-prefs.tsx b/src/components/plan/module-plan-prefs.tsx
index 0e2e424..5335802 100644
--- a/src/components/plan/module-plan-prefs.tsx
+++ b/src/components/plan/module-plan-prefs.tsx
@@ -167,7 +167,8 @@ export function ModulePlanPrefs({
{moduleName}
-
+ {/* Stack the fields under sm so they don't cram on phones (D4). */}
+
{weightField}
{hoursField}
{phaseField}
diff --git a/src/components/plan/today-plan-card.tsx b/src/components/plan/today-plan-card.tsx
index 7bd118d..cfa322b 100644
--- a/src/components/plan/today-plan-card.tsx
+++ b/src/components/plan/today-plan-card.tsx
@@ -1,5 +1,6 @@
"use client"
+import * as React from "react"
import { CalendarClock } from "lucide-react"
import { useTranslations } from "next-intl"
import { useActionErrorToast } from "@/components/action-error-toast"
@@ -24,16 +25,20 @@ export function TodayPlanCard({ items }: { items: TodayPlanSession[] }) {
const t = useTranslations("semesterPlan")
const showError = useActionErrorToast()
const router = useRouter()
+ const [pending, startTransition] = React.useTransition()
if (items.length === 0) return null
- async function onToggle(id: string, done: boolean) {
- try {
- await toggleSession(id, done)
- router.refresh()
- } catch (error) {
- showError(error)
- }
+ function onToggle(id: string, done: boolean) {
+ // Disable during flight so rapid taps can't double-fire (D13).
+ startTransition(async () => {
+ try {
+ await toggleSession(id, done)
+ router.refresh()
+ } catch (error) {
+ showError(error)
+ }
+ })
}
return (
@@ -63,7 +68,8 @@ export function TodayPlanCard({ items }: { items: TodayPlanSession[] }) {
void onToggle(item.id, Boolean(on))}
+ disabled={pending}
+ onCheckedChange={(on) => onToggle(item.id, Boolean(on))}
/>
{item.moduleName ?? ""}
diff --git a/src/components/studies/module-tabs.tsx b/src/components/studies/module-tabs.tsx
index c771d10..c0ea262 100644
--- a/src/components/studies/module-tabs.tsx
+++ b/src/components/studies/module-tabs.tsx
@@ -43,38 +43,42 @@ export function ModuleTabs({
}
return (
-