From 5e3128a35e6ad468ff7ab6c19e2981de0fcf15dc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 08:58:04 +0000 Subject: [PATCH 1/4] fix(materials): visible processing pipeline, missing-file handling, robust PDF worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The processing pipeline maintained detailed per-material state (extractionStatus/extractionError) that the UI never showed — a stuck or failed material looked identical to a ready one, and several failure modes were completely silent. This makes the whole pipeline observable and recoverable: - materials list shows pipeline badges (queued/extracting/indexing/ summarizing spinner, failed with error tooltip, skipped with reason) and polls while anything is still processing - new 'process again' action re-enqueues failed/skipped materials (processing is idempotent, so retries are safe) - skipped materials now record WHY (no transcription provider, no vision model, no extractable text) instead of silently showing nothing - a failed job enqueue marks the material failed instead of pretending the upload fully succeeded; summarization errors can no longer leave a material stuck on the 'summarizing' spinner forever - material viewer detects a missing stored file (DB row outliving the file, e.g. uploads lost before the volume fix) and explains it — previously the PDF viewer surfaced a cryptic pdf.js error - pdf.js worker is copied to public/ at build time and loaded from a stable same-origin URL — the bare-specifier new URL(...) pattern is not guaranteed to emit an asset under Turbopack, which would break ALL PDF rendering - /api/health now write-probes the storage backend and the admin page warns when storage is unwritable (the most likely cause of failing uploads) - upload errors are mapped to actionable messages (rate limit, network, server/storage) instead of raw statusText - materials page hints when no embedding model is configured (files were 'ready' but silently unsearchable) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UrdkggYqhNCCZVHyJrdwBR --- .gitignore | 1 + messages/de.json | 26 ++++- messages/en.json | 26 ++++- package.json | 4 +- scripts/copy-pdf-worker.mjs | 12 +++ src/app/[locale]/(app)/admin/page.tsx | 26 ++++- src/app/[locale]/(app)/materials-actions.ts | 16 +++ .../[locale]/(app)/materials/[id]/page.tsx | 22 ++++- .../[programId]/[moduleId]/materials/page.tsx | 7 +- src/app/api/health/route.ts | 28 +++++- .../materials/materials-browser.tsx | 99 ++++++++++++++++++- src/components/materials/pdf-annotator.tsx | 23 ++++- src/components/materials/upload-client.ts | 35 ++++++- src/components/materials/upload-dialog.tsx | 4 +- src/lib/ai/generation/summarize.ts | 16 +++ src/lib/ai/rag.ts | 40 ++++++-- src/lib/materials/ingest.ts | 11 ++- 17 files changed, 356 insertions(+), 40 deletions(-) create mode 100644 scripts/copy-pdf-worker.mjs diff --git a/.gitignore b/.gitignore index 33bfac6..1cbc373 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,4 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts +public/pdf.worker.min.mjs diff --git a/messages/de.json b/messages/de.json index 50130a4..c64bc3c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -123,7 +123,8 @@ "unban": "Entsperren", "delete": "Löschen", "deleteConfirm": "Benutzer und alle zugehörigen Daten unwiderruflich löschen?", - "updated": "Benutzer aktualisiert." + "updated": "Benutzer aktualisiert.", + "storageUnwritable": "Der Datei-Speicher ist nicht beschreibbar — Uploads schlagen fehl. Prüfe das Upload-Volume (UPLOAD_DIR) und dessen Berechtigungen." }, "auth": { "title": "Anmeldung & SSO", @@ -764,11 +765,14 @@ "download": "Herunterladen", "open": "Öffnen", "viewer": { - "unsupported": "Vorschau für diesen Dateityp nicht verfügbar — bitte herunterladen." + "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." }, "annotator": { "hint": "Auf der Seite ziehen zum Markieren; Markierung anklicken für Notiz oder Löschen.", - "notePlaceholder": "Notiz zu dieser Markierung …" + "notePlaceholder": "Notiz zu dieser Markierung …", + "fileMissing": "Die Datei fehlt auf dem Server — vermutlich ging sie bei einem früheren Redeploy verloren. Bitte lade sie neu hoch." }, "stats": { "files": "{count, plural, =1 {# Datei} other {# Dateien}}", @@ -791,7 +795,21 @@ "uploadingCount": "Lädt hoch … {done}/{total}", "uploadHint": "Wähle Dateien oder einen ganzen Ordner. Ordner behalten ihre Struktur; .zip-Archive werden automatisch entpackt.", "chooseFiles": "Dateien wählen", - "chooseFolder": "Ordner wählen" + "chooseFolder": "Ordner wählen", + "processing": { + "pending": "Wartet", + "extracting": "Extrahiert", + "embedding": "Indexiert", + "summarizing": "Fasst zusammen", + "failed": "Verarbeitung fehlgeschlagen", + "skipped": "Nicht verarbeitet", + "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.", + "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." }, "search": { "modules": "Module", diff --git a/messages/en.json b/messages/en.json index bd5fa51..b31cf8a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -123,7 +123,8 @@ "unban": "Unban", "delete": "Delete", "deleteConfirm": "Permanently delete this user and all associated data?", - "updated": "User updated." + "updated": "User updated.", + "storageUnwritable": "File storage is not writable — uploads will fail. Check the upload volume (UPLOAD_DIR) and its permissions." }, "auth": { "title": "Sign-in & SSO", @@ -764,11 +765,14 @@ "download": "Download", "open": "Open", "viewer": { - "unsupported": "No preview available for this file type — please download." + "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." }, "annotator": { "hint": "Drag on a page to highlight; click a highlight to add a note or delete it.", - "notePlaceholder": "Note for this highlight …" + "notePlaceholder": "Note for this highlight …", + "fileMissing": "The file is missing on the server — it was probably lost in an earlier redeploy. Please upload it again." }, "stats": { "files": "{count, plural, =1 {# file} other {# files}}", @@ -791,7 +795,21 @@ "uploadingCount": "Uploading … {done}/{total}", "uploadHint": "Choose files or a whole folder. Folders keep their structure; .zip archives are unpacked automatically.", "chooseFiles": "Choose files", - "chooseFolder": "Choose folder" + "chooseFolder": "Choose folder", + "processing": { + "pending": "Queued", + "extracting": "Extracting", + "embedding": "Indexing", + "summarizing": "Summarizing", + "failed": "Processing failed", + "skipped": "Not processed", + "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.", + "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." }, "search": { "modules": "Modules", diff --git a/package.json b/package.json index a49c90e..a2f59b3 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,11 @@ { "name": "study-helper", - "version": "1.0.15", + "version": "1.1.0", "private": true, "scripts": { + "predev": "node scripts/copy-pdf-worker.mjs", "dev": "next dev", + "prebuild": "node scripts/copy-pdf-worker.mjs", "build": "next build", "start": "next start", "worker": "NODE_OPTIONS=--conditions=react-server tsx src/worker.ts", diff --git a/scripts/copy-pdf-worker.mjs b/scripts/copy-pdf-worker.mjs new file mode 100644 index 0000000..b6ad6dd --- /dev/null +++ b/scripts/copy-pdf-worker.mjs @@ -0,0 +1,12 @@ +// Copies the pdf.js worker into public/ so the annotator can load it from a +// stable same-origin URL. Bundler-independent (Turbopack's handling of bare +// specifiers inside `new URL(..., import.meta.url)` is not guaranteed), and +// the copy always matches the installed pdfjs-dist API version. Runs via the +// predev/prebuild npm hooks. +import { copyFile } from "node:fs/promises" +import { createRequire } from "node:module" + +const require = createRequire(import.meta.url) +const src = require.resolve("pdfjs-dist/build/pdf.worker.min.mjs") +await copyFile(src, "public/pdf.worker.min.mjs") +console.log("[copy-pdf-worker] public/pdf.worker.min.mjs updated") diff --git a/src/app/[locale]/(app)/admin/page.tsx b/src/app/[locale]/(app)/admin/page.tsx index 67cb7d8..f38b3f3 100644 --- a/src/app/[locale]/(app)/admin/page.tsx +++ b/src/app/[locale]/(app)/admin/page.tsx @@ -1,8 +1,10 @@ import { desc } from "drizzle-orm" +import { AlertTriangle } from "lucide-react" import { getTranslations, getFormatter } from "next-intl/server" import { db } from "@/db" import { user } from "@/db/schema" import { requireAdmin } from "@/lib/auth/session" +import { deleteFile, saveFile } from "@/lib/storage" import { UserActions } from "@/components/admin/user-actions" import { Badge } from "@/components/ui/badge" import { @@ -13,13 +15,34 @@ import { CardTitle, } from "@/components/ui/card" +/** Write-probe of the storage backend (same check as /api/health). */ +async function storageWritable(): Promise { + try { + const path = await saveFile("_health", `probe-${crypto.randomUUID()}.txt`, Buffer.from("ok")) + await deleteFile(path) + return true + } catch { + return false + } +} + export default async function AdminUsersPage() { const session = await requireAdmin() const t = await getTranslations("admin.users") const format = await getFormatter() - const users = await db.select().from(user).orderBy(desc(user.createdAt)) + const [users, writable] = await Promise.all([ + db.select().from(user).orderBy(desc(user.createdAt)), + storageWritable(), + ]) return ( + <> + {!writable && ( +
+ +

{t("storageUnwritable")}

+
+ )} {t("title")} @@ -71,5 +94,6 @@ export default async function AdminUsersPage() { + ) } diff --git a/src/app/[locale]/(app)/materials-actions.ts b/src/app/[locale]/(app)/materials-actions.ts index ede0ec0..7d22f92 100644 --- a/src/app/[locale]/(app)/materials-actions.ts +++ b/src/app/[locale]/(app)/materials-actions.ts @@ -64,6 +64,22 @@ async function ownMaterial(materialId: string, userId: string) { return row } +/** Re-runs extraction/embedding for a file material (idempotent — already + * embedded chunks for the active model are skipped). */ +export async function retryMaterialProcessing(materialId: string) { + const session = await requireSession() + const row = await ownMaterial(materialId, session.user.id) + if (row.kind !== "file") throw new Error("Not a file material") + await db + .update(material) + .set({ extractionStatus: "pending", extractionError: null }) + .where(eq(material.id, materialId)) + const { enqueueEmbedMaterial } = await import("@/lib/jobs") + await enqueueEmbedMaterial(materialId) + revalidatePath("/", "layout") + return { ok: true as const } +} + export async function renameMaterial(materialId: string, name: string) { const session = await requireSession() const clean = name.trim().slice(0, 300) diff --git a/src/app/[locale]/(app)/materials/[id]/page.tsx b/src/app/[locale]/(app)/materials/[id]/page.tsx index a5c4101..9297ea2 100644 --- a/src/app/[locale]/(app)/materials/[id]/page.tsx +++ b/src/app/[locale]/(app)/materials/[id]/page.tsx @@ -6,6 +6,7 @@ import { asc } from "drizzle-orm" import { db } from "@/db" import { material, materialAnnotation } from "@/db/schema" import { requireSession } from "@/lib/auth/session" +import { fileSize } from "@/lib/storage" import { Link } from "@/i18n/navigation" import { MediaPlayer } from "@/components/materials/media-player" import { PdfAnnotator } from "@/components/materials/pdf-annotator" @@ -33,6 +34,18 @@ export default async function MaterialViewerPage({ const fileUrl = `/api/materials/${row.id}/file` const mime = row.mimeType ?? "" + // The DB row can outlive the stored file (e.g. uploads lost to a redeploy + // before the storage volume was mounted). Detect that here and explain it, + // instead of every viewer failing with its own cryptic error. + let fileAvailable = Boolean(row.storagePath) + if (fileAvailable) { + try { + await fileSize(row.storagePath!) + } catch { + fileAvailable = false + } + } + const annotations = mime === "application/pdf" ? await db.query.materialAnnotation.findMany({ @@ -58,7 +71,14 @@ export default async function MaterialViewerPage({ - {mime === "application/pdf" ? ( + {!fileAvailable ? ( + + +

{t("viewer.fileMissing")}

+

{t("viewer.fileMissingHint")}

+
+
+ ) : mime === "application/pdf" ? ( ({ id: m.id, kind: m.kind, @@ -37,6 +40,8 @@ export default async function ModuleMaterialsPage({ sizeBytes: m.sizeBytes, folderId: m.folderId, createdAt: m.createdAt.toISOString(), + extractionStatus: m.extractionStatus, + extractionError: m.extractionError, }))} folders={folders.map((f) => ({ id: f.id, parentId: f.parentId, name: f.name }))} /> diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index 1c76ca9..36036b1 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -1,14 +1,38 @@ import { NextResponse } from "next/server" import { sql } from "drizzle-orm" import { db } from "@/db" +import { deleteFile, saveFile } from "@/lib/storage" 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. + */ +async function checkStorage(): Promise { + const probe = `health-${crypto.randomUUID()}.txt` + try { + const path = await saveFile("_health", probe, Buffer.from("ok")) + await deleteFile(path) + return true + } catch (error) { + console.error("[health] storage write check failed", error) + return false + } +} + export async function GET() { + let database = "up" try { await db.execute(sql`select 1`) - return NextResponse.json({ status: "ok", database: "up" }) } catch { - return NextResponse.json({ status: "degraded", database: "down" }, { status: 503 }) + database = "down" } + const storage = (await checkStorage()) ? "up" : "down" + const ok = database === "up" && storage === "up" + return NextResponse.json( + { status: ok ? "ok" : "degraded", database, storage }, + { status: ok ? 200 : 503 } + ) } diff --git a/src/components/materials/materials-browser.tsx b/src/components/materials/materials-browser.tsx index ea056d6..56d5fa2 100644 --- a/src/components/materials/materials-browser.tsx +++ b/src/components/materials/materials-browser.tsx @@ -32,6 +32,7 @@ import { Music, Pencil, Presentation, + RefreshCw, Trash2, Video, X, @@ -49,6 +50,7 @@ import { moveMaterialToFolder, renameFolder, renameMaterial, + retryMaterialProcessing, } from "@/app/[locale]/(app)/materials-actions" import { ConfirmDeleteDialog } from "@/components/studies/confirm-delete-dialog" import { EntityContextMenu, type ContextMenuAction } from "@/components/entity-context-menu" @@ -68,7 +70,7 @@ import { } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" import { cn, formatBytes } from "@/lib/utils" -import { readDroppedItems, uploadFiles } from "./upload-client" +import { describeUploadError, readDroppedItems, uploadFiles } from "./upload-client" import { LinkDialog, UploadDialog } from "./upload-dialog" const ROOT = "__root__" @@ -82,6 +84,51 @@ export type MaterialItem = { sizeBytes: number | null folderId: string | null createdAt: string + /** AI extraction/embedding pipeline state (files only). */ + extractionStatus?: string | null + extractionError?: string | null +} + +/** Pipeline states that are still moving — used to poll for fresh status. */ +const ACTIVE_STATUSES = ["pending", "extracting", "embedding", "summarizing"] + +/** + * Compact pipeline-state badge for a file material. "ready" (and links) render + * nothing; active states show a spinner; failed/skipped show why and offer a + * retry via the row menu. + */ +function ProcessingBadge({ item }: { item: MaterialItem }) { + const t = useTranslations("materials.processing") + const status = item.extractionStatus + if (item.kind !== "file" || !status || status === "ready") return null + if (ACTIVE_STATUSES.includes(status)) { + return ( + + + {t(status)} + + ) + } + if (status === "failed") { + return ( + + + {t("failed")} + + ) + } + // skipped + return ( + + {t("skipped")} + + ) } export type FolderNode = { id: string; parentId: string | null; name: string } @@ -235,6 +282,7 @@ function FileItem({ onRename, onMove, onDelete, + onRetry, }: { item: MaterialItem view: "list" | "grid" @@ -244,6 +292,7 @@ function FileItem({ onRename: () => void onMove: () => void onDelete: () => void + onRetry: () => void }) { const t = useTranslations("materials") const tCommon = useTranslations("common") @@ -251,9 +300,16 @@ function FileItem({ const { attributes, listeners, setNodeRef: dragRef, style: dragStyle, isDragging } = useItemDraggable(item.id) + const canRetry = + item.kind === "file" && + (item.extractionStatus === "failed" || item.extractionStatus === "skipped") + const actions: ContextMenuAction[] = [ { label: t("rename"), icon: Pencil, onSelect: onRename }, { label: t("moveTo"), icon: FolderInput, onSelect: onMove }, + ...(canRetry + ? [{ label: t("processing.retry"), icon: RefreshCw, onSelect: onRetry }] + : []), { label: tCommon("delete"), icon: Trash2, destructive: true, onSelect: onDelete, separatorBefore: true }, ] @@ -278,6 +334,9 @@ function FileItem({ {t("rename")} {t("moveTo")} + {canRetry && ( + {t("processing.retry")} + )} {tCommon("delete")} @@ -296,10 +355,11 @@ function FileItem({
{nameEl}
-

+

{formatBytes(item.sizeBytes)} {item.sizeBytes != null && " · "} {format.dateTime(new Date(item.createdAt), { dateStyle: "medium" })} +

{grip} @@ -312,6 +372,7 @@ function FileItem({ {grip} {nameEl} + {formatBytes(item.sizeBytes)} {item.sizeBytes != null && " · "} @@ -389,10 +450,13 @@ export function MaterialsBrowser({ moduleId, materials, folders, + indexingEnabled = true, }: { moduleId: string materials: MaterialItem[] folders: FolderNode[] + /** False when no embedding model is configured — materials won't be searchable. */ + indexingEnabled?: boolean }) { const t = useTranslations("materials") const router = useRouter() @@ -418,6 +482,26 @@ export function MaterialsBrowser({ const saved = window.localStorage.getItem("materials-view-mode") if (saved === "grid" || saved === "list") queueMicrotask(() => setView(saved)) }, []) + + // While any material is still extracting/embedding, refresh periodically so + // the pipeline badges move without a manual reload. + const hasActiveProcessing = materials.some( + (m) => m.extractionStatus && ACTIVE_STATUSES.includes(m.extractionStatus) + ) + React.useEffect(() => { + if (!hasActiveProcessing) return + const id = setInterval(() => router.refresh(), 5000) + return () => clearInterval(id) + }, [hasActiveProcessing, router]) + + async function retryProcessing(materialId: string) { + try { + await retryMaterialProcessing(materialId) + router.refresh() + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)) + } + } function changeView(next: "list" | "grid") { setView(next) window.localStorage.setItem("materials-view-mode", next) @@ -597,7 +681,7 @@ export function MaterialsBrowser({ if (queued > 0) toast.success(t("unpacking")) else toast.success(t("uploaded")) } catch (error) { - toast.error(t("uploadFailed", { error: error instanceof Error ? error.message : String(error) })) + toast.error(t("uploadFailed", { error: describeUploadError(error, t) })) } finally { setUploadingMsg(null) router.refresh() @@ -660,6 +744,11 @@ export function MaterialsBrowser({ return (
+ {!indexingEnabled && materials.some((m) => m.kind === "file") && ( +

+ {t("notIndexedHint")} +

+ )} {/* Stats bar */}
{t("stats.files", { count: totalFiles })} @@ -724,7 +813,7 @@ export function MaterialsBrowser({ setCurrentFolderId(f.id)} onRename={() => { setRenameTarget({ kind: "folder", id: f.id, name: f.name }); setRenameValue(f.name) }} onNewSubfolder={() => { setNewFolderParent(f.id); setNewFolderName("") }} onMove={() => setMoveTarget({ kind: "folder", id: f.id })} onDelete={() => setDeleteTarget({ kind: "folder", id: f.id, label: f.name })} /> ))} {currentFiles.map((item, i) => ( - { toggleSelect(item.id, c); lastIndexRef.current = i }} onRowClick={(e) => onFileRowClick(e, i, item.id)} onRename={() => { setRenameTarget({ kind: "file", id: item.id, name: item.name }); setRenameValue(item.name) }} onMove={() => setMoveTarget({ kind: "file", id: item.id })} onDelete={() => setDeleteTarget({ kind: "file", id: item.id, label: item.name })} /> + { toggleSelect(item.id, c); lastIndexRef.current = i }} onRowClick={(e) => onFileRowClick(e, i, item.id)} onRename={() => { setRenameTarget({ kind: "file", id: item.id, name: item.name }); setRenameValue(item.name) }} onMove={() => setMoveTarget({ kind: "file", id: item.id })} onDelete={() => setDeleteTarget({ kind: "file", id: item.id, label: item.name })} onRetry={() => void retryProcessing(item.id)} /> ))}
) : ( @@ -734,7 +823,7 @@ export function MaterialsBrowser({ ))}
    {currentFiles.map((item, i) => ( - { toggleSelect(item.id, c); lastIndexRef.current = i }} onRowClick={(e) => onFileRowClick(e, i, item.id)} onRename={() => { setRenameTarget({ kind: "file", id: item.id, name: item.name }); setRenameValue(item.name) }} onMove={() => setMoveTarget({ kind: "file", id: item.id })} onDelete={() => setDeleteTarget({ kind: "file", id: item.id, label: item.name })} /> + { toggleSelect(item.id, c); lastIndexRef.current = i }} onRowClick={(e) => onFileRowClick(e, i, item.id)} onRename={() => { setRenameTarget({ kind: "file", id: item.id, name: item.name }); setRenameValue(item.name) }} onMove={() => setMoveTarget({ kind: "file", id: item.id })} onDelete={() => setDeleteTarget({ kind: "file", id: item.id, label: item.name })} onRetry={() => void retryProcessing(item.id)} /> ))}
diff --git a/src/components/materials/pdf-annotator.tsx b/src/components/materials/pdf-annotator.tsx index 9710d2f..a314952 100644 --- a/src/components/materials/pdf-annotator.tsx +++ b/src/components/materials/pdf-annotator.tsx @@ -60,10 +60,10 @@ export function PdfAnnotator({ ;(async () => { try { const pdfjs = await import("pdfjs-dist") - pdfjs.GlobalWorkerOptions.workerSrc = new URL( - "pdfjs-dist/build/pdf.worker.min.mjs", - import.meta.url - ).toString() + // The worker is copied to public/ at build time (scripts/copy-pdf-worker.mjs) + // — a stable same-origin URL that works under every bundler; the version + // query busts caches when pdfjs-dist is upgraded. + pdfjs.GlobalWorkerOptions.workerSrc = `/pdf.worker.min.mjs?v=${pdfjs.version}` const pdf = await pdfjs.getDocument({ url: fileUrl }).promise if (cancelled) return pdfRef.current = pdf @@ -75,7 +75,20 @@ export function PdfAnnotator({ } if (!cancelled) setPages(infos) } catch (err) { - if (!cancelled) setError(err instanceof Error ? err.message : String(err)) + if (cancelled) return + // Distinguish "the stored file is gone" (e.g. lost to a redeploy before + // uploads were volume-backed) from a real render error — the former + // needs a human-readable explanation, not a pdf.js stack message. + try { + const probe = await fetch(fileUrl, { headers: { Range: "bytes=0-0" } }) + if (probe.status === 404) { + setError(t("fileMissing")) + return + } + } catch { + // probe failed — fall through to the generic error + } + setError(err instanceof Error ? err.message : String(err)) } })() return () => { diff --git a/src/components/materials/upload-client.ts b/src/components/materials/upload-client.ts index 1aa611a..37be2c5 100644 --- a/src/components/materials/upload-client.ts +++ b/src/components/materials/upload-client.ts @@ -7,6 +7,18 @@ export type UploadItem = { file: File; relativePath?: string } export type UploadProgress = { done: number; total: number; percent: number } +/** Upload failure carrying the HTTP status so the UI can explain the cause. */ +export class UploadError extends Error { + constructor( + message: string, + readonly status: number, + readonly fileName: string + ) { + super(message) + this.name = "UploadError" + } +} + /** * Uploads one large file via the resumable tus protocol. Survives interruptions: * a dropped connection is retried from the server's last byte offset, and a @@ -78,18 +90,35 @@ function xhrUpload( resolve({}) } } else { + let message = xhr.statusText try { - reject(new Error(JSON.parse(xhr.responseText).error ?? xhr.statusText)) + message = JSON.parse(xhr.responseText).error ?? xhr.statusText } catch { - reject(new Error(xhr.statusText)) + // keep statusText } + reject(new UploadError(message, xhr.status, file.name)) } } - xhr.onerror = () => reject(new Error("network error")) + xhr.onerror = () => reject(new UploadError("network error", 0, file.name)) xhr.send(file) }) } +/** + * Maps an upload failure onto a translated, user-actionable message. + * `t` is the "materials" namespace translator. + */ +export function describeUploadError(error: unknown, t: (key: string, values?: Record) => string): string { + if (error instanceof UploadError) { + if (error.status === 413) return `${error.fileName}: ${error.message}` + if (error.status === 429) return t("uploadRateLimited") + if (error.status === 0) return t("uploadNetworkError") + if (error.status >= 500) return t("uploadServerError", { name: error.fileName }) + return `${error.fileName}: ${error.message}` + } + return error instanceof Error ? error.message : String(error) +} + /** * Uploads a list of files sequentially, reporting progress. Each item may carry * a `relativePath` so the server can recreate a nested folder structure. Zip diff --git a/src/components/materials/upload-dialog.tsx b/src/components/materials/upload-dialog.tsx index ef7cb92..d8b8395 100644 --- a/src/components/materials/upload-dialog.tsx +++ b/src/components/materials/upload-dialog.tsx @@ -16,7 +16,7 @@ import { import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { createLinkMaterial } from "@/app/[locale]/(app)/materials-actions" -import { uploadFiles, type UploadItem } from "./upload-client" +import { describeUploadError, uploadFiles, type UploadItem } from "./upload-client" export function UploadDialog({ moduleId, folderId }: { moduleId: string; folderId: string | null }) { const t = useTranslations("materials") @@ -37,7 +37,7 @@ export function UploadDialog({ moduleId, folderId }: { moduleId: string; folderI toast.success(queued > 0 ? t("unpacking") : t("uploaded")) setOpen(false) } catch (error) { - toast.error(t("uploadFailed", { error: error instanceof Error ? error.message : String(error) })) + toast.error(t("uploadFailed", { error: describeUploadError(error, t) })) } finally { setProgress(null) router.refresh() diff --git a/src/lib/ai/generation/summarize.ts b/src/lib/ai/generation/summarize.ts index 037b62f..28627f3 100644 --- a/src/lib/ai/generation/summarize.ts +++ b/src/lib/ai/generation/summarize.ts @@ -129,6 +129,22 @@ export async function summarizeMaterial(materialId: string): Promise { .set({ extractionStatus: "summarizing" }) .where(eq(material.id, materialId)) + try { + await runSummarization(row, materialId, modelRef) + } catch (error) { + // Summaries are best-effort — never leave the material stuck in + // "summarizing" (an eternal spinner in the UI) because of a model error. + await db.update(material).set({ extractionStatus: "ready" }).where(eq(material.id, materialId)) + throw error + } +} + +async function runSummarization( + row: typeof material.$inferSelect, + materialId: string, + modelRef: string +): Promise { + const sections = await getSectionTexts(row) const model = await getLanguageModel(modelRef, row.userId) let usage: AiUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 } const acc = (u: AiUsage) => { diff --git a/src/lib/ai/rag.ts b/src/lib/ai/rag.ts index 9a109b6..cc23fea 100644 --- a/src/lib/ai/rag.ts +++ b/src/lib/ai/rag.ts @@ -66,9 +66,9 @@ export async function processMaterial(materialId: string): Promise { if (!row || row.kind !== "file" || !row.storagePath) return try { - const text = await ensureExtractedText(row) + const { text, skipReason } = await ensureExtractedText(row) if (text == null) { - await setStatus(materialId, "skipped") + await setStatus(materialId, "skipped", { extractionError: skipReason ?? null }) return } @@ -100,10 +100,12 @@ export async function processMaterial(materialId: string): Promise { * a bounded preview + char count are stored on the row (so multi-GB documents * are not capped by a single DB column, and re-runs don't re-extract). */ -async function ensureExtractedText(row: typeof material.$inferSelect): Promise { +async function ensureExtractedText( + row: typeof material.$inferSelect +): Promise<{ text: string | null; skipReason?: string }> { if (row.textStoragePath && row.charCount != null) { try { - return await readStoredText(row.textStoragePath) + return { text: await readStoredText(row.textStoragePath) } } catch { // stored text missing — fall through and re-extract } @@ -111,19 +113,37 @@ async function ensureExtractedText(row: typeof material.$inferSelect): Promise Date: Sat, 18 Jul 2026 08:59:10 +0000 Subject: [PATCH 2/4] fix(materials): visible processing pipeline, missing-file handling, robust PDF worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The processing pipeline maintained detailed per-material state (extractionStatus/extractionError) that the UI never showed — a stuck or failed material looked identical to a ready one, and several failure modes were completely silent. This makes the whole pipeline observable and recoverable: - materials list shows pipeline badges (queued/extracting/indexing/ summarizing spinner, failed with error tooltip, skipped with reason) and polls while anything is still processing - new 'process again' action re-enqueues failed/skipped materials (processing is idempotent, so retries are safe) - skipped materials now record WHY (no transcription provider, no vision model, no extractable text) instead of silently showing nothing - a failed job enqueue marks the material failed instead of pretending the upload fully succeeded; summarization errors can no longer leave a material stuck on the 'summarizing' spinner forever - material viewer detects a missing stored file (DB row outliving the file, e.g. uploads lost before the volume fix) and explains it — previously the PDF viewer surfaced a cryptic pdf.js error - pdf.js worker is copied to public/ at build time and loaded from a stable same-origin URL — the bare-specifier new URL(...) pattern is not guaranteed to emit an asset under Turbopack, which would break ALL PDF rendering - /api/health now write-probes the storage backend and the admin page warns when storage is unwritable (the most likely cause of failing uploads) - upload errors are mapped to actionable messages (rate limit, network, server/storage) instead of raw statusText - materials page hints when no embedding model is configured (files were 'ready' but silently unsearchable) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UrdkggYqhNCCZVHyJrdwBR --- eslint.config.mjs | 2 ++ src/components/materials/pdf-annotator.tsx | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..53c4114 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,6 +12,8 @@ const eslintConfig = defineConfig([ "out/**", "build/**", "next-env.d.ts", + // Generated at build time by scripts/copy-pdf-worker.mjs (minified vendor code). + "public/pdf.worker.min.mjs", ]), ]); diff --git a/src/components/materials/pdf-annotator.tsx b/src/components/materials/pdf-annotator.tsx index a314952..16b3b7f 100644 --- a/src/components/materials/pdf-annotator.tsx +++ b/src/components/materials/pdf-annotator.tsx @@ -94,7 +94,7 @@ export function PdfAnnotator({ return () => { cancelled = true } - }, [fileUrl]) + }, [fileUrl, t]) async function onCreate(page: number, rect: AnnotationRect) { const temp: PdfAnnotation = { id: `tmp-${crypto.randomUUID()}`, page, rect, color, note: null } From ab5a35942e0147e2227561957d27c5ed0a954574 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 09:08:38 +0000 Subject: [PATCH 3/4] feat(ai): re-embed backfill, tuned generation params, German FTS, provider validation Core AI-engineering fixes from the v1.1 review: - re-embed backfill: changing the default embedding model used to silently orphan every existing chunk (vector search filters on the active model ref and matched nothing, with no signal and no recovery). A new reembed-materials job re-embeds stale materials (reusing stored text, resumable), removes old-model chunks only after success, rebuilds the ANN index at the end, auto-starts on model change and has an admin card with a stale-count warning and progress - generation parameters: every SDK call ran on raw provider defaults. Central params now set low temperature for content generation (cards/questions/outline/summaries/plans), temperature 0 for free-text grading (identical answers no longer flip between correct/incorrect) and OCR, an explicit chat temperature, and an output-token budget on the live generation path matching the batch path (prevents silent mid-JSON truncation -> schema failures) - grading uses an explicit rubric; card/question prompts get quality rubrics (atomic cards, plausible distractors, difficulty mix, evenly distributed correct positions) - full-text search switches from the 'simple' config to 'german' (stemming + stop words; the app's content is German-first) and from plainto_tsquery to websearch_to_tsquery (never throws, supports phrases); migration rebuilds the generated column + GIN index - retrieval noise cut: vector-only hits below a conservative cosine threshold are dropped before RRF fusion (nearest-neighbour always returns something, even when the corpus doesn't cover the query) - chat stream errors are categorized (auth / rate-limit / generic) and shown as translated messages instead of being swallowed; failed streams no longer persist empty assistant messages - provider/API-key validation: admin 'Test connection' button fires a one-token live request against the (unsaved) provider config, and BYOK keys are validated the same way before being stored - admin usage view gains a per-feature/per-model breakdown Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UrdkggYqhNCCZVHyJrdwBR --- drizzle/0036_motionless_rick_jones.sql | 7 + drizzle/meta/0036_snapshot.json | 5815 ++++++++++++++++++++ drizzle/meta/_journal.json | 7 + messages/de.json | 21 +- messages/en.json | 21 +- src/app/[locale]/(app)/admin/actions.ts | 50 +- src/app/[locale]/(app)/admin/ai/page.tsx | 91 +- src/app/[locale]/(app)/learn-actions.ts | 3 + src/app/[locale]/(app)/quiz-actions.ts | 16 +- src/app/[locale]/(app)/settings/actions.ts | 8 +- src/app/api/ai/chat/route.ts | 19 + src/components/admin/ai-settings-form.tsx | 41 +- src/components/admin/reembed-card.tsx | 117 + src/components/ai/chat.tsx | 13 +- src/db/schema/material-chunks.ts | 8 +- src/lib/ai/generation/generate.ts | 24 +- src/lib/ai/generation/outline.ts | 2 + src/lib/ai/generation/summarize.ts | 5 +- src/lib/ai/media.ts | 1 + src/lib/ai/params.ts | 35 + src/lib/ai/rag.ts | 20 +- src/lib/ai/reembed.ts | 115 + src/lib/ai/registry.ts | 24 + src/lib/jobs/index.ts | 17 + src/lib/settings.ts | 17 + 25 files changed, 6461 insertions(+), 36 deletions(-) create mode 100644 drizzle/0036_motionless_rick_jones.sql create mode 100644 drizzle/meta/0036_snapshot.json create mode 100644 src/components/admin/reembed-card.tsx create mode 100644 src/lib/ai/params.ts create mode 100644 src/lib/ai/reembed.ts diff --git a/drizzle/0036_motionless_rick_jones.sql b/drizzle/0036_motionless_rick_jones.sql new file mode 100644 index 0000000..a7b82fe --- /dev/null +++ b/drizzle/0036_motionless_rick_jones.sql @@ -0,0 +1,7 @@ +-- Switch full-text search to the 'german' config (stemming + stop words). +-- A generated column cannot be altered in place; drop + re-add rewrites the +-- table and recomputes the vectors. Dropping the column also drops the GIN +-- index, so it is recreated afterwards. +ALTER TABLE "material_chunk" drop column "content_tsv";--> statement-breakpoint +ALTER TABLE "material_chunk" ADD COLUMN "content_tsv" "tsvector" GENERATED ALWAYS AS (to_tsvector('german', "material_chunk"."content")) STORED;--> statement-breakpoint +CREATE INDEX "material_chunk_tsv_idx" ON "material_chunk" USING gin ("content_tsv"); diff --git a/drizzle/meta/0036_snapshot.json b/drizzle/meta/0036_snapshot.json new file mode 100644 index 0000000..abbea1f --- /dev/null +++ b/drizzle/meta/0036_snapshot.json @@ -0,0 +1,5815 @@ +{ + "id": "646344f3-aa87-45b9-8f9f-a668339bee23", + "prevId": "3ac42aee-7be4-49fa-8409-1e9d11bcecd3", + "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 + }, + "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" + } + }, + "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 + }, + "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" + } + }, + "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.assessment_attempt": { + "name": "assessment_attempt", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "assessment_id": { + "name": "assessment_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": { + "assessment_attempt_assessmentId_idx": { + "name": "assessment_attempt_assessmentId_idx", + "columns": [ + { + "expression": "assessment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assessment_attempt_assessment_id_module_assessment_id_fk": { + "name": "assessment_attempt_assessment_id_module_assessment_id_fk", + "tableFrom": "assessment_attempt", + "tableTo": "module_assessment", + "columnsFrom": [ + "assessment_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.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_assessment": { + "name": "module_assessment", + "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, + "default": "'exam'" + }, + "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_assessment_moduleId_idx": { + "name": "module_assessment_moduleId_idx", + "columns": [ + { + "expression": "module_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "module_assessment_module_id_module_id_fk": { + "name": "module_assessment_module_id_module_id_fk", + "tableFrom": "module_assessment", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "module_assessment_module_id_unique": { + "name": "module_assessment_module_id_unique", + "nullsNotDistinct": false, + "columns": [ + "module_id" + ] + } + }, + "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.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 + }, + "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": {} + } + }, + "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" + } + }, + "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 + }, + "exam_type": { + "name": "exam_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'planned'" + }, + "is_thesis": { + "name": "is_thesis", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "bonus_type": { + "name": "bonus_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "bonus_value": { + "name": "bonus_value", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false + }, + "bonus_min_avg_percent": { + "name": "bonus_min_avg_percent", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false + }, + "bonus_min_completed_share": { + "name": "bonus_min_completed_share", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false + }, + "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.study_plan": { + "name": "study_plan", + "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": { + "study_plan_userId_idx": { + "name": "study_plan_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "study_plan_user_id_user_id_fk": { + "name": "study_plan_user_id_user_id_fk", + "tableFrom": "study_plan", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "study_plan_module_id_module_id_fk": { + "name": "study_plan_module_id_module_id_fk", + "tableFrom": "study_plan", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.study_plan_item": { + "name": "study_plan_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan_id": { + "name": "plan_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 + }, + "scheduled_date": { + "name": "scheduled_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "duration_minutes": { + "name": "duration_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "done": { + "name": "done", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "study_plan_item_planId_idx": { + "name": "study_plan_item_planId_idx", + "columns": [ + { + "expression": "plan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "study_plan_item_plan_id_study_plan_id_fk": { + "name": "study_plan_item_plan_id_study_plan_id_fk", + "tableFrom": "study_plan_item", + "tableTo": "study_plan", + "columnsFrom": [ + "plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.thesis_milestone": { + "name": "thesis_milestone", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "thesis_id": { + "name": "thesis_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": { + "thesis_milestone_thesisId_idx": { + "name": "thesis_milestone_thesisId_idx", + "columns": [ + { + "expression": "thesis_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "thesis_milestone_thesis_id_thesis_project_id_fk": { + "name": "thesis_milestone_thesis_id_thesis_project_id_fk", + "tableFrom": "thesis_milestone", + "tableTo": "thesis_project", + "columnsFrom": [ + "thesis_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.thesis_project": { + "name": "thesis_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 + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thesis_type": { + "name": "thesis_type", + "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": { + "thesis_project_userId_idx": { + "name": "thesis_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": "\"thesis_project\".\"superseded_by_id\" is null and \"thesis_project\".\"program_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "thesis_project_user_id_user_id_fk": { + "name": "thesis_project_user_id_user_id_fk", + "tableFrom": "thesis_project", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "thesis_project_program_id_degree_program_id_fk": { + "name": "thesis_project_program_id_degree_program_id_fk", + "tableFrom": "thesis_project", + "tableTo": "degree_program", + "columnsFrom": [ + "program_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "thesis_project_semester_id_semester_id_fk": { + "name": "thesis_project_semester_id_semester_id_fk", + "tableFrom": "thesis_project", + "tableTo": "semester", + "columnsFrom": [ + "semester_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "thesis_project_superseded_by_id_thesis_project_id_fk": { + "name": "thesis_project_superseded_by_id_thesis_project_id_fk", + "tableFrom": "thesis_project", + "tableTo": "thesis_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 + }, + "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.semester_plan_item": { + "name": "semester_plan_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignment_id": { + "name": "assignment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'study'" + }, + "title": { + "name": "title", + "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": false + }, + "duration_minutes": { + "name": "duration_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60 + }, + "done": { + "name": "done", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "semester_plan_item_plan_idx": { + "name": "semester_plan_item_plan_idx", + "columns": [ + { + "expression": "plan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "semester_plan_item_plan_id_semester_plan_id_fk": { + "name": "semester_plan_item_plan_id_semester_plan_id_fk", + "tableFrom": "semester_plan_item", + "tableTo": "semester_plan", + "columnsFrom": [ + "plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "semester_plan_item_module_id_module_id_fk": { + "name": "semester_plan_item_module_id_module_id_fk", + "tableFrom": "semester_plan_item", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "semester_plan_item_assignment_id_assignment_id_fk": { + "name": "semester_plan_item_assignment_id_assignment_id_fk", + "tableFrom": "semester_plan_item", + "tableTo": "assignment", + "columnsFrom": [ + "assignment_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 d7e1d49..f8736ba 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -253,6 +253,13 @@ "when": 1784359449655, "tag": "0035_fair_killmonger", "breakpoints": true + }, + { + "idx": 36, + "version": "7", + "when": 1784365495581, + "tag": "0036_motionless_rick_jones", + "breakpoints": true } ] } \ No newline at end of file diff --git a/messages/de.json b/messages/de.json index c64bc3c..6b0033c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -195,7 +195,23 @@ "annStatusFailed": "Fehlgeschlagen", "annRebuild": "Index (neu) aufbauen", "annRebuilding": "Index-Aufbau gestartet – läuft im Hintergrund.", - "annNeedEmbedding": "Zuerst ein Standard-Embedding-Modell konfigurieren." + "annNeedEmbedding": "Zuerst ein Standard-Embedding-Modell konfigurieren.", + "reembedTitle": "Embedding-Backfill", + "reembedDescription": "Nach einem Wechsel des Embedding-Modells müssen bestehende Materialien neu eingebettet werden — sonst findet die AI-Suche sie nicht mehr.", + "reembedStaleWarning": "{count} Material(ien) sind mit einem anderen Modell eingebettet und derzeit nicht per Vektorsuche auffindbar.", + "reembedRunning": "Re-Embedding läuft… {done}/{total}", + "reembedDone": "Abgeschlossen: {done} neu eingebettet, {failed} fehlgeschlagen.", + "reembedFailed": "Fehlgeschlagen", + "reembedIdle": "Kein Backfill nötig oder noch nie gelaufen.", + "reembedStart": "Jetzt neu einbetten", + "reembedStarted": "Re-Embedding gestartet.", + "usageByFeature": "Nutzung nach Feature & Modell (30 Tage)", + "usageFeature": "Feature", + "usageModel": "Modell", + "usageCalls": "Aufrufe", + "testConnection": "Verbindung testen", + "testOk": "Verbindung erfolgreich — der API-Key funktioniert.", + "testFailed": "Verbindungstest fehlgeschlagen: {error}" }, "email": { "title": "E-Mail (SMTP)", @@ -896,7 +912,8 @@ "history": "Verlauf" }, "errorGeneric": "Die Anfrage ist fehlgeschlagen. Bitte versuche es erneut.", - "errorLimit": "Dein monatliches AI-Kontingent ist aufgebraucht." + "errorLimit": "Dein monatliches AI-Kontingent ist aufgebraucht.", + "errorAuth": "Der AI-Anbieter hat die Anfrage abgelehnt (API-Key ungültig oder abgelaufen). Bitte prüfe die AI-Einstellungen." }, "learn": { "title": "Lernen", diff --git a/messages/en.json b/messages/en.json index b31cf8a..63ed00c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -195,7 +195,23 @@ "annStatusFailed": "Failed", "annRebuild": "Build / rebuild index", "annRebuilding": "Index build started — running in the background.", - "annNeedEmbedding": "Configure a default embedding model first." + "annNeedEmbedding": "Configure a default embedding model first.", + "reembedTitle": "Embedding backfill", + "reembedDescription": "After changing the embedding model, existing materials must be re-embedded — otherwise AI search can no longer find them.", + "reembedStaleWarning": "{count} material(s) are embedded with a different model and currently invisible to vector search.", + "reembedRunning": "Re-embedding… {done}/{total}", + "reembedDone": "Finished: {done} re-embedded, {failed} failed.", + "reembedFailed": "Failed", + "reembedIdle": "No backfill needed or never run.", + "reembedStart": "Re-embed now", + "reembedStarted": "Re-embedding started.", + "usageByFeature": "Usage by feature & model (30 days)", + "usageFeature": "Feature", + "usageModel": "Model", + "usageCalls": "Calls", + "testConnection": "Test connection", + "testOk": "Connection successful — the API key works.", + "testFailed": "Connection test failed: {error}" }, "email": { "title": "Email (SMTP)", @@ -896,7 +912,8 @@ "history": "History" }, "errorGeneric": "The request failed. Please try again.", - "errorLimit": "Your monthly AI quota is used up." + "errorLimit": "Your monthly AI quota is used up.", + "errorAuth": "The AI provider rejected the request (API key invalid or expired). Please check the AI settings." }, "learn": { "title": "Learn", diff --git a/src/app/[locale]/(app)/admin/actions.ts b/src/app/[locale]/(app)/admin/actions.ts index c82d6ba..d6d5056 100644 --- a/src/app/[locale]/(app)/admin/actions.ts +++ b/src/app/[locale]/(app)/admin/actions.ts @@ -110,7 +110,24 @@ export async function saveUploads(value: unknown) { export async function saveAiSettings(value: unknown) { await requireAdmin() - await setSetting("ai", aiSettingsSchema.parse(value)) + const { getSetting } = await import("@/lib/settings") + const previous = await getSetting("ai") + const next = aiSettingsSchema.parse(value) + await setSetting("ai", next) + // A changed embedding model orphans every existing chunk (vector search + // filters on the active model ref) — kick off the re-embed backfill so + // search doesn't silently go dark. + if ( + next.defaultEmbeddingModel && + previous?.defaultEmbeddingModel !== next.defaultEmbeddingModel + ) { + try { + const { enqueueReembedMaterials } = await import("@/lib/jobs") + await enqueueReembedMaterials() + } catch (error) { + console.error("[admin] failed to enqueue re-embed after model change", error) + } + } revalidatePath("/", "layout") return { ok: true as const } } @@ -129,3 +146,34 @@ export async function getAnnStatus() { const { getSetting } = await import("@/lib/settings") return (await getSetting("ai.ann")) ?? { status: "idle" as const } } + +/** Fires a one-token test request against a (possibly unsaved) provider config. */ +export async function testAiProvider(value: unknown) { + await requireAdmin() + const { aiProviderSchema } = await import("@/lib/settings") + const provider = aiProviderSchema.parse(value) + const { testProviderConnection } = await import("@/lib/ai/registry") + return testProviderConnection(provider, provider.apiKey) +} + +/** Starts the re-embed backfill for materials embedded with a stale model. */ +export async function startReembed() { + await requireAdmin() + const { enqueueReembedMaterials } = await import("@/lib/jobs") + await enqueueReembedMaterials() + return { ok: true as const } +} + +/** Re-embed progress + live count of stale materials, for the admin UI. */ +export async function getReembedStatus() { + await requireAdmin() + const { getSetting } = await import("@/lib/settings") + const [state, ai] = await Promise.all([getSetting("ai.reembed"), getSetting("ai")]) + const ref = ai?.defaultEmbeddingModel + let staleCount = 0 + if (ref) { + const { countStaleMaterials } = await import("@/lib/ai/reembed") + staleCount = await countStaleMaterials(ref) + } + return { state: state ?? { status: "idle" as const }, staleCount, embeddingModel: ref ?? null } +} diff --git a/src/app/[locale]/(app)/admin/ai/page.tsx b/src/app/[locale]/(app)/admin/ai/page.tsx index 66fcb41..29ec378 100644 --- a/src/app/[locale]/(app)/admin/ai/page.tsx +++ b/src/app/[locale]/(app)/admin/ai/page.tsx @@ -3,39 +3,63 @@ import { getTranslations } from "next-intl/server" import { db } from "@/db" import { aiUsageLog, user } from "@/db/schema" import { requireAdmin } from "@/lib/auth/session" +import { countStaleMaterials } from "@/lib/ai/reembed" import { getSetting } from "@/lib/settings" import { daysAgo } from "@/lib/utils" import { AiSettingsForm } from "@/components/admin/ai-settings-form" import { AnnIndexCard } from "@/components/admin/ann-index-card" +import { ReembedCard } from "@/components/admin/reembed-card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" export default async function AdminAiPage() { await requireAdmin() const t = await getTranslations("admin.ai") const ai = await getSetting("ai") - const ann = await getSetting("ai.ann") + const embeddingRef = ai?.defaultEmbeddingModel const thirtyDaysAgo = daysAgo(30) - const usage = await db - .select({ - userName: user.name, - inputTokens: sum(aiUsageLog.inputTokens).mapWith(Number), - outputTokens: sum(aiUsageLog.outputTokens).mapWith(Number), - }) - .from(aiUsageLog) - .innerJoin(user, eq(aiUsageLog.userId, user.id)) - .where(gte(aiUsageLog.createdAt, thirtyDaysAgo)) - .groupBy(user.id, user.name) - .orderBy(desc(sql`sum(${aiUsageLog.inputTokens} + ${aiUsageLog.outputTokens})`)) + const [ann, reembed, staleCount, usage, usageByFeature] = await Promise.all([ + getSetting("ai.ann"), + getSetting("ai.reembed"), + embeddingRef ? countStaleMaterials(embeddingRef) : Promise.resolve(0), + db + .select({ + userName: user.name, + inputTokens: sum(aiUsageLog.inputTokens).mapWith(Number), + outputTokens: sum(aiUsageLog.outputTokens).mapWith(Number), + }) + .from(aiUsageLog) + .innerJoin(user, eq(aiUsageLog.userId, user.id)) + .where(gte(aiUsageLog.createdAt, thirtyDaysAgo)) + .groupBy(user.id, user.name) + .orderBy(desc(sql`sum(${aiUsageLog.inputTokens} + ${aiUsageLog.outputTokens})`)), + db + .select({ + feature: aiUsageLog.feature, + model: aiUsageLog.model, + calls: sql`count(*)::int`, + inputTokens: sum(aiUsageLog.inputTokens).mapWith(Number), + outputTokens: sum(aiUsageLog.outputTokens).mapWith(Number), + }) + .from(aiUsageLog) + .where(gte(aiUsageLog.createdAt, thirtyDaysAgo)) + .groupBy(aiUsageLog.feature, aiUsageLog.model) + .orderBy(desc(sql`sum(${aiUsageLog.inputTokens} + ${aiUsageLog.outputTokens})`)), + ]) return (
+ @@ -70,6 +94,47 @@ export default async function AdminAiPage() { )} + {usageByFeature.length > 0 && ( + + + {t("usageByFeature")} + + +
+ + + + + + + + + + + + {usageByFeature.map((row) => ( + + + + + + + + ))} + +
{t("usageFeature")}{t("usageModel")}{t("usageCalls")}{t("usageInput")}{t("usageOutput")}
{row.feature} + {row.model} + + {row.calls.toLocaleString()} + + {row.inputTokens.toLocaleString()} + + {row.outputTokens.toLocaleString()} +
+
+
+
+ )}
) } diff --git a/src/app/[locale]/(app)/learn-actions.ts b/src/app/[locale]/(app)/learn-actions.ts index 5697890..0535092 100644 --- a/src/app/[locale]/(app)/learn-actions.ts +++ b/src/app/[locale]/(app)/learn-actions.ts @@ -1,4 +1,5 @@ "use server" +import { GEN_PARAMS } from "@/lib/ai/params" import { revalidatePath } from "next/cache" import { and, eq } from "drizzle-orm" @@ -237,6 +238,7 @@ export async function generateStudyPlan(input: unknown) { () => generateObject({ model, + ...GEN_PARAMS, schema: generatedPlanSchema, prompt: `Create a realistic study plan for a university student. Today is ${today}. The exam is on ${data.examDate}. @@ -366,6 +368,7 @@ export async function analyzeProgress(moduleId: string) { () => generateText({ model, + ...GEN_PARAMS, prompt: `You are a study coach. Analyze this learning history for one university module and tell the student what to deepen next. Requirements: - Answer in the language of the quiz/flashcard content (German if mixed). diff --git a/src/app/[locale]/(app)/quiz-actions.ts b/src/app/[locale]/(app)/quiz-actions.ts index 36c2941..f8ba871 100644 --- a/src/app/[locale]/(app)/quiz-actions.ts +++ b/src/app/[locale]/(app)/quiz-actions.ts @@ -1,4 +1,5 @@ "use server" +import { GEN_PARAMS, GRADING_PARAMS } from "@/lib/ai/params" import { revalidatePath } from "next/cache" import { and, eq, isNull } from "drizzle-orm" @@ -260,6 +261,7 @@ export async function generateQuiz(input: unknown) { () => generateObject({ model, + ...GEN_PARAMS, schema: generatedQuizSchema, prompt: `Create a quiz with ${data.count} exam-style questions about: ${query}. ${data.mixed ? "Mix multiple_choice (with exactly 4 plausible options) and free_text questions (about 70/30)." : "Use only multiple_choice questions with exactly 4 plausible options."} @@ -365,11 +367,21 @@ export async function submitAttempt(input: unknown): Promise { () => generateObject({ model, + // Deterministic: a re-submitted identical answer must not flip + // between correct and incorrect. + ...GRADING_PARAMS, schema: z.object({ correct: z.boolean(), feedback: z.string() }), - prompt: `Grade this student answer. Question: "${prompt}" + prompt: `Grade this student answer against the reference. +Question: "${prompt}" Reference answer: "${reference}" Student answer: "${answer}" -Judge leniently on wording but strictly on content. Reply with correct=true/false and one sentence of feedback in the language of the question.`, + +Grading rubric: +- correct=true only if the answer contains the core facts/concepts of the reference; wording, order and minor omissions do not matter. +- correct=false if a core fact is missing, wrong, or contradicted — even when parts are right. +- An empty, off-topic or "I don't know" answer is always false. +- Ignore spelling/grammar entirely. +Reply with correct=true/false and one sentence of feedback in the language of the question.`, }) ) return object diff --git a/src/app/[locale]/(app)/settings/actions.ts b/src/app/[locale]/(app)/settings/actions.ts index 64f9598..4fc8229 100644 --- a/src/app/[locale]/(app)/settings/actions.ts +++ b/src/app/[locale]/(app)/settings/actions.ts @@ -19,7 +19,13 @@ export async function saveUserAiKey(input: unknown) { const { providerId, apiKey } = keySchema.parse(input) const { getSetting } = await import("@/lib/settings") const ai = await getSetting("ai") - if (!ai?.providers.some((p) => p.id === providerId)) throw new Error("Unknown provider") + const provider = ai?.providers.find((p) => p.id === providerId) + if (!provider) throw new Error("Unknown provider") + // Validate the key with a one-token request before storing it — a bad key + // would otherwise only surface later, deep inside a chat stream or job. + const { testProviderConnection } = await import("@/lib/ai/registry") + const test = await testProviderConnection(provider, apiKey) + if (!test.ok) throw new Error(`Key check failed: ${test.error}`) const encrypted = encrypt(apiKey) const existing = await db.query.userAiKey.findFirst({ where: and(eq(userAiKey.userId, session.user.id), eq(userAiKey.providerId, providerId)), diff --git a/src/app/api/ai/chat/route.ts b/src/app/api/ai/chat/route.ts index 82887c4..9cbac79 100644 --- a/src/app/api/ai/chat/route.ts +++ b/src/app/api/ai/chat/route.ts @@ -11,6 +11,7 @@ import { searchChunks } from "@/lib/ai/rag" import { MODE_PROMPTS, type ChatMode } from "@/lib/ai/modes" import { writeToolDescriptions, writeToolSchemas, WRITE_TOOL_NAMES } from "@/lib/ai/tools" import { mergeToolOutputs } from "@/lib/ai/chat-history" +import { CHAT_PARAMS } from "@/lib/ai/params" import { getStudyContext } from "@/lib/studies/context" import { getModuleDetail } from "@/lib/studies/module-detail" import { getModuleFinalGrades } from "@/lib/studies/grades-server" @@ -191,6 +192,7 @@ export async function POST(request: Request) { const result = streamText({ model, + ...CHAT_PARAMS, system: buildSystemPrompt( conversation.module?.name, conversation.moduleId, @@ -334,7 +336,24 @@ export async function POST(request: Request) { void Promise.resolve(result.consumeStream()).catch(() => {}) return result.toUIMessageStreamResponse({ + // Categorize provider failures for the client (which maps the codes onto + // translated messages) without leaking raw provider/stack details. + onError: (error) => { + console.error("[chat] stream error", error) + const message = error instanceof Error ? error.message : String(error) + if (/api.?key|unauthorized|authentication|401|403/i.test(message)) return "AI_ERROR:auth" + if (/rate.?limit|quota|429|overloaded/i.test(message)) return "AI_ERROR:rate-limit" + return "AI_ERROR:generic" + }, onFinish: async ({ responseMessage }) => { + // A failed stream can produce an empty assistant message — persisting it + // would litter the conversation with blank turns. + const hasContent = responseMessage.parts.some( + (p) => + (p.type === "text" && p.text.trim().length > 0) || + (typeof p.type === "string" && p.type.startsWith("tool-")) + ) + if (!hasContent) return await db.insert(aiMessage).values({ conversationId: conversation.id, role: "assistant", diff --git a/src/components/admin/ai-settings-form.tsx b/src/components/admin/ai-settings-form.tsx index cef9b8c..b91e77b 100644 --- a/src/components/admin/ai-settings-form.tsx +++ b/src/components/admin/ai-settings-form.tsx @@ -23,7 +23,7 @@ import { SelectValue, } from "@/components/ui/select" import { Switch } from "@/components/ui/switch" -import { saveAiSettings } from "@/app/[locale]/(app)/admin/actions" +import { saveAiSettings, testAiProvider } from "@/app/[locale]/(app)/admin/actions" import type { AiSettings } from "@/lib/settings" const PROVIDER_TYPES = [ @@ -83,6 +83,34 @@ export function AiSettingsForm({ initial }: { initial: AiSettings }) { setProviders((list) => list.map((p, i) => (i === index ? { ...p, ...patch } : p))) } + const [testing, setTesting] = React.useState(null) + + /** One-token live request against the (possibly unsaved) provider config. */ + async function testProvider(index: number) { + const p = providers[index] + setTesting(p.id) + try { + const result = await testAiProvider({ + id: p.id, + type: p.type, + name: p.name || p.id, + apiKey: p.apiKey || undefined, + baseUrl: p.baseUrl || undefined, + models: p.modelsText + .split(",") + .map((m) => m.trim()) + .filter(Boolean), + embeddingModel: p.embeddingModel.trim() || undefined, + }) + if (result.ok) toast.success(t("testOk")) + else toast.error(t("testFailed", { error: result.error })) + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)) + } finally { + setTesting(null) + } + } + async function save() { setPending(true) try { @@ -199,7 +227,16 @@ export function AiSettingsForm({ initial }: { initial: AiSettings }) { /> -
+
+ + {!embeddingConfigured && ( +

{t("annNeedEmbedding")}

+ )} + + + ) +} diff --git a/src/components/ai/chat.tsx b/src/components/ai/chat.tsx index dcd43e5..eaac6f5 100644 --- a/src/components/ai/chat.tsx +++ b/src/components/ai/chat.tsx @@ -320,11 +320,14 @@ export function Chat({ )} {error && (

- {/* Raw error.message can carry transport/provider internals — log - it for debugging, show the user a friendly message. */} - {error.message.toLowerCase().includes("limit") - ? t("errorLimit") - : t("errorGeneric")} + {/* Server categorizes stream errors as AI_ERROR:; anything + else stays generic so provider internals never reach the UI. */} + {error.message === "AI_ERROR:auth" + ? t("errorAuth") + : error.message === "AI_ERROR:rate-limit" || + error.message.toLowerCase().includes("limit") + ? t("errorLimit") + : t("errorGeneric")}

)}
diff --git a/src/db/schema/material-chunks.ts b/src/db/schema/material-chunks.ts index 3da9033..917f21a 100644 --- a/src/db/schema/material-chunks.ts +++ b/src/db/schema/material-chunks.ts @@ -47,9 +47,13 @@ export const materialChunk = pgTable( * retrieval). Improves retrieval precision on ambiguous chunks. */ contextualHeader: text("contextual_header"), - /** Full-text search vector over `content`, for hybrid (lexical + vector) search. */ + /** Full-text search vector over `content`, for hybrid (lexical + vector) + * search. Uses the 'german' config (stemming + stop words) — the app's + * content is mostly German; English tokens still index as exact matches, + * and the vector leg of hybrid search covers English recall. Must match + * the query config in rag.ts. */ contentTsv: tsvector("content_tsv").generatedAlwaysAs( - (): SQL => sql`to_tsvector('simple', ${materialChunk.content})` + (): SQL => sql`to_tsvector('german', ${materialChunk.content})` ), }, (t) => [ diff --git a/src/lib/ai/generation/generate.ts b/src/lib/ai/generation/generate.ts index a53f76d..2a63451 100644 --- a/src/lib/ai/generation/generate.ts +++ b/src/lib/ai/generation/generate.ts @@ -14,6 +14,7 @@ import { type GenerationKind, } from "@/db/schema" import { getSetting } from "@/lib/settings" +import { GEN_PARAMS, maxTokensForItems } from "@/lib/ai/params" import { getLanguageModel, resolveModelForUser } from "@/lib/ai/registry" import { runAi } from "@/lib/ai/run" import { assertWithinLimit } from "@/lib/ai/usage" @@ -62,7 +63,14 @@ export function buildCardPrompt( return `Create up to ${count} high-quality spaced-repetition flashcards about the topic "${topic.title}".${ topic.summary ? ` Topic scope: ${topic.summary}` : "" } -Base the cards strictly on the excerpts below — do not invent facts not present. Each card: a concise question/term on the front, a precise answer/definition on the back. Write all cards in ${language}. +Base the cards strictly on the excerpts below — do not invent facts not present. + +Quality rubric: +- Atomic: each card tests exactly ONE fact, definition, distinction or step — never a list of several. +- The front is a specific question or term (not "Explain X" for a whole chapter); the back is the precise, complete answer in 1-3 sentences. +- Prefer why/how/compare cards over pure recall where the excerpts support it; skip trivia that no exam would ask. +- No card may be answerable from its own front text alone, and no two cards may test the same fact. +Write all cards in ${language}. Excerpts: ${grounding || "(no excerpts available — use the topic title/scope)"}` @@ -80,7 +88,13 @@ export function buildQuestionPrompt( topic.summary ? ` Topic scope: ${topic.summary}` : "" } ${mixed ? "Mix multiple_choice (exactly 4 plausible options, correctIndex set) and free_text (referenceAnswer set), about 70/30." : "Use only multiple_choice questions with exactly 4 plausible options and correctIndex set."} -Give each question a short explanation of the correct answer. Base questions strictly on the excerpts. Write everything in ${language}. + +Quality rubric: +- Vary difficulty: roughly half recall/understanding, half application/analysis (small scenarios, "which of these is NOT…", cause-effect). +- Multiple choice: all 4 options must be plausible and mutually exclusive; distractors reflect real misconceptions; avoid "all/none of the above"; distribute correctIndex evenly, never always the same position. +- Free text: the question must be answerable in 1-4 sentences and the referenceAnswer must contain every fact required for full credit. +- Base every question strictly on the excerpts; give each question a short explanation of the correct answer. +Write everything in ${language}. Excerpts: ${grounding || "(no excerpts available — use the topic title/scope)"}` @@ -186,6 +200,8 @@ async function generateTopicCards( model, schema: cardSchema, prompt: buildCardPrompt(topic, grounding, count, language), + ...GEN_PARAMS, + maxOutputTokens: maxTokensForItems(count), }) ) return object.cards.slice(0, count) @@ -218,6 +234,8 @@ async function generateTopicQuestions( model, schema: questionSchema, prompt: buildQuestionPrompt(topic, grounding, count, mixed, language), + ...GEN_PARAMS, + maxOutputTokens: maxTokensForItems(count), }) ) return object.questions.slice(0, count) @@ -347,7 +365,7 @@ async function fail(jobId: string, message: string): Promise { /** Batch-request token budget per topic (Anthropic requires max_tokens). */ function batchMaxTokens(count: number): number { - return Math.min(8000, Math.max(1500, count * 400)) + return maxTokensForItems(count) } /** diff --git a/src/lib/ai/generation/outline.ts b/src/lib/ai/generation/outline.ts index a717549..e8770ec 100644 --- a/src/lib/ai/generation/outline.ts +++ b/src/lib/ai/generation/outline.ts @@ -1,4 +1,5 @@ import "server-only" +import { GEN_PARAMS } from "@/lib/ai/params" import { createHash } from "node:crypto" import { generateObject } from "ai" import { and, asc, eq } from "drizzle-orm" @@ -159,6 +160,7 @@ export async function buildModuleOutline( }, () => generateObject({ + ...GEN_PARAMS, model, schema: topicSchema, prompt: `You are building a complete study outline for one university course module from summaries of ALL its materials. Produce a de-duplicated, comprehensive list of topics such that generating flashcards/quiz questions per topic would cover the ENTIRE material — miss nothing, but do not duplicate topics. Use more topics for larger/broader corpora (roughly one topic per distinct concept area). For each topic give: a concise title, a 1-3 sentence summary of what it covers, the material tags that cover it (from the list), and a weight 1-10 for how much of the corpus it represents.\n\nMaterials:\n${catalog}`, diff --git a/src/lib/ai/generation/summarize.ts b/src/lib/ai/generation/summarize.ts index 28627f3..0138808 100644 --- a/src/lib/ai/generation/summarize.ts +++ b/src/lib/ai/generation/summarize.ts @@ -1,4 +1,5 @@ import "server-only" +import { GEN_PARAMS } from "@/lib/ai/params" import { generateText, embedMany } from "ai" import { and, asc, eq, gt } from "drizzle-orm" import { db } from "@/db" @@ -165,7 +166,7 @@ async function runSummarization( entityLabel: row.name, audit: false, }, - () => generateText({ model, prompt: SECTION_PROMPT(section) }) + () => generateText({ model, prompt: SECTION_PROMPT(section), ...GEN_PARAMS }) ) acc(aiUsage) const trimmed = text.trim() @@ -203,7 +204,7 @@ async function runSummarization( entityLabel: row.name, audit: false, }, - () => generateText({ model, prompt: REDUCE_PROMPT(group) }) + () => generateText({ model, prompt: REDUCE_PROMPT(group), ...GEN_PARAMS }) ) acc(aiUsage) next.push(text.trim()) diff --git a/src/lib/ai/media.ts b/src/lib/ai/media.ts index b06dde3..d63db16 100644 --- a/src/lib/ai/media.ts +++ b/src/lib/ai/media.ts @@ -36,6 +36,7 @@ export async function extractImageText( }, () => generateText({ + temperature: 0, model, messages: [ { diff --git a/src/lib/ai/params.ts b/src/lib/ai/params.ts new file mode 100644 index 0000000..c5b01e0 --- /dev/null +++ b/src/lib/ai/params.ts @@ -0,0 +1,35 @@ +import "server-only" + +/** + * Central generation parameters. Every SDK call used to run on raw provider + * defaults (typically temperature ~0.7–1.0 and an implicit output cap), which + * is wrong for most of this app's work: + * - content generation (cards/questions/outlines/summaries/plans) must stay + * faithful to the source excerpts → low temperature, + * - grading must be reproducible — the same answer must not flip between + * correct and incorrect on a re-submit → temperature 0, + * - the live generation path needs an explicit output budget like the batch + * path already had, or large requests get silently truncated mid-JSON and + * fail schema parsing. + */ + +/** Faithful, low-variance content generation grounded in source material. */ +export const GEN_PARAMS = { temperature: 0.2 } as const + +/** Deterministic evaluation (free-text answer grading). */ +export const GRADING_PARAMS = { temperature: 0 } as const + +/** Conversational chat — some variance is desirable. */ +export const CHAT_PARAMS = { temperature: 0.7 } as const + +/** Short utility calls (conversation titles etc.). */ +export const UTILITY_PARAMS = { temperature: 0.3 } as const + +/** + * Output-token budget per generated batch of `count` items — mirrors the batch + * path's budget (generate.ts batchMaxTokens) so live and batch generation + * behave identically. + */ +export function maxTokensForItems(count: number): number { + return Math.min(8000, Math.max(1500, count * 400)) +} diff --git a/src/lib/ai/rag.ts b/src/lib/ai/rag.ts index cc23fea..4a791fc 100644 --- a/src/lib/ai/rag.ts +++ b/src/lib/ai/rag.ts @@ -286,12 +286,26 @@ type ScoredRow = { * retrieval robust to the vector model missing exact terms/acronyms/formulae * (which lexical catches) and vice-versa. */ +/** + * Vector-only hits below this cosine similarity are cut before fusion: nearest- + * neighbour search always returns *something*, even for a query the corpus + * doesn't cover, and those noise chunks would otherwise flow into prompts. + * Deliberately conservative (clearly-unrelated territory across common + * embedding models); hits that also match lexically are never cut — a term + * match is real signal regardless of the cosine value. + */ +const MIN_VECTOR_SIMILARITY = 0.15 + function rrfFuse( vector: ScoredRow[], lexical: Omit[], limit: number ): RagHit[] { const K = 60 + const lexicalIds = new Set(lexical.map((r) => r.id)) + vector = vector.filter( + (r) => r.similarity >= MIN_VECTOR_SIMILARITY || lexicalIds.has(r.id) + ) const acc = new Map() vector.forEach((r, i) => { acc.set(r.id, { @@ -339,7 +353,11 @@ async function hybridSearch( const base = and(eq(material.userId, userId), ...extraWhere) // Lexical ranking via Postgres full-text search over the generated tsvector. - const tsq = sql`plainto_tsquery('simple', ${query})` + // websearch_to_tsquery never throws on arbitrary (user/model-supplied) query + // strings and supports quoted phrases. Config MUST match the generated + // column's config (schema/material-chunks.ts) or stemming mismatches kill + // recall despite the GIN index. + const tsq = sql`websearch_to_tsquery('german', ${query})` const lexical = await db .select({ id: materialChunk.id, diff --git a/src/lib/ai/reembed.ts b/src/lib/ai/reembed.ts new file mode 100644 index 0000000..222971a --- /dev/null +++ b/src/lib/ai/reembed.ts @@ -0,0 +1,115 @@ +import "server-only" +import { sql } from "drizzle-orm" +import { db } from "@/db" +import { getSetting, setSetting } from "@/lib/settings" +import { processMaterial } from "./rag" + +/** + * Re-embeds every material whose chunks were embedded with a different model + * than the active `ai.defaultEmbeddingModel`. Without this, changing the + * embedding model silently orphans all existing chunks: vector search filters + * on the active model ref and matches nothing. + * + * Resumable/best-effort: `processMaterial` reuses the stored extracted text + * (no re-OCR cost) and skips chunks already embedded for the active model; a + * failed material is counted and skipped rather than aborting the batch. + * Progress is tracked in the `ai.reembed` setting for the admin UI. + */ +export async function reembedStaleMaterials(): Promise { + const ai = await getSetting("ai") + const ref = ai?.defaultEmbeddingModel + if (!ref) { + await setSetting("ai.reembed", { status: "failed", error: "No embedding model configured" }) + return + } + + const staleIds = await findStaleMaterialIds(ref) + if (staleIds.length === 0) { + await setSetting("ai.reembed", { status: "done", embeddingModel: ref, total: 0, done: 0 }) + return + } + + let done = 0 + let failed = 0 + const progress = () => + setSetting("ai.reembed", { + status: "running", + embeddingModel: ref, + total: staleIds.length, + done, + failed, + }) + await progress() + + for (const materialId of staleIds) { + // Abort (and leave state resumable) when an admin changes the model again + // mid-run — the auto-enqueue on save starts a fresh backfill. + const current = (await getSetting("ai"))?.defaultEmbeddingModel + if (current !== ref) { + await setSetting("ai.reembed", { + status: "failed", + embeddingModel: ref, + total: staleIds.length, + done, + failed, + error: "Embedding model changed mid-run — restart the re-embed.", + }) + return + } + + try { + await processMaterial(materialId) + // Old-model chunks would still surface as duplicate lexical (FTS) hits — + // remove them only after the new embedding succeeded, so search never + // loses content mid-backfill. + await db.execute(sql` + DELETE FROM material_chunk + WHERE material_id = ${materialId} + AND embedding_model IS DISTINCT FROM ${ref} + `) + const { enqueueSummarizeMaterial } = await import("@/lib/jobs") + await enqueueSummarizeMaterial(materialId) + done++ + } catch (error) { + console.error("[reembed] material failed", materialId, error) + failed++ + } + await progress() + } + + await setSetting("ai.reembed", { + status: "done", + embeddingModel: ref, + total: staleIds.length, + done, + failed, + }) + + // The HNSW ANN shadow column (if ever built) is typed for the old model's + // dimension — rebuild it for the new one now that the chunk set is final. + const ann = await getSetting("ai.ann") + if (ann && ann.status !== "idle") { + const { enqueueReindexVectors } = await import("@/lib/jobs") + await enqueueReindexVectors() + } +} + +/** Materials that still have chunks embedded with a non-active model. */ +export async function findStaleMaterialIds(activeRef: string): Promise { + const rows = await db.execute<{ material_id: string }>(sql` + SELECT DISTINCT material_id + FROM material_chunk + WHERE embedding_model IS DISTINCT FROM ${activeRef} + `) + return rows.map((r) => r.material_id) +} + +/** Count for the admin banner ("N materials need re-embedding"). */ +export async function countStaleMaterials(activeRef: string): Promise { + const rows = await db.execute<{ count: number }>(sql` + SELECT count(DISTINCT material_id)::int AS count + FROM material_chunk + WHERE embedding_model IS DISTINCT FROM ${activeRef} + `) + return Number(rows[0]?.count ?? 0) +} diff --git a/src/lib/ai/registry.ts b/src/lib/ai/registry.ts index 41b0e97..303a3d8 100644 --- a/src/lib/ai/registry.ts +++ b/src/lib/ai/registry.ts @@ -52,6 +52,30 @@ function instantiate(provider: AiProvider, apiKey: string | undefined) { } } +/** + * Fires a minimal real request against a provider to verify its API key / + * endpoint before it gets saved and fails later deep inside a stream or a + * background job. Uses the given model (or the provider's default/first) with + * a one-token completion. + */ +export async function testProviderConnection( + provider: AiProvider, + apiKey: string | undefined, + modelId?: string +): Promise<{ ok: true } | { ok: false; error: string }> { + const { generateText } = await import("ai") + const id = modelId || provider.models[0] + if (!id) return { ok: false, error: "No model configured for this provider" } + try { + const sdk = instantiate(provider, apiKey) + await generateText({ model: sdk.languageModel(id), prompt: "ping", maxOutputTokens: 1 }) + return { ok: true } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { ok: false, error: message.slice(0, 300) } + } +} + async function getProvider(providerId: string): Promise { const ai = await getSetting("ai") const provider = ai?.providers.find((p) => p.id === providerId) diff --git a/src/lib/jobs/index.ts b/src/lib/jobs/index.ts index 81835d1..f98b189 100644 --- a/src/lib/jobs/index.ts +++ b/src/lib/jobs/index.ts @@ -9,6 +9,7 @@ export const QUEUE_SUMMARIZE_MATERIAL = "summarize-material" export const QUEUE_GENERATE_COVERAGE = "generate-coverage" export const QUEUE_POLL_BATCHES = "poll-batches" export const QUEUE_REINDEX_VECTORS = "reindex-vectors" +export const QUEUE_REEMBED_MATERIALS = "reembed-materials" export const QUEUE_UNPACK_ZIP = "unpack-zip" export const QUEUE_FINALIZE_UPLOAD = "finalize-upload" export const QUEUE_SEND_REMINDERS = "send-reminders" @@ -21,6 +22,7 @@ const ALL_QUEUES = [ QUEUE_GENERATE_COVERAGE, QUEUE_POLL_BATCHES, QUEUE_REINDEX_VECTORS, + QUEUE_REEMBED_MATERIALS, QUEUE_UNPACK_ZIP, QUEUE_FINALIZE_UPLOAD, QUEUE_SEND_REMINDERS, @@ -89,6 +91,11 @@ export async function registerWorkers(boss: PgBoss): Promise { await reindexVectors() }) + await boss.work(QUEUE_REEMBED_MATERIALS, async () => { + const { reembedStaleMaterials } = await import("@/lib/ai/reembed") + await reembedStaleMaterials() + }) + await boss.work(QUEUE_UNPACK_ZIP, async (jobs) => { const { unpackZip } = await import("./unpack-zip") for (const job of jobs) { @@ -202,6 +209,16 @@ export async function enqueueReindexVectors(): Promise { ) } +export async function enqueueReembedMaterials(): Promise { + const boss = await getBoss() + // Long-running backfill; one at a time, resumable via a manual re-trigger. + await boss.send( + QUEUE_REEMBED_MATERIALS, + {}, + { retryLimit: 0, singletonKey: "reembed", expireInSeconds: 4 * 3600 } + ) +} + export async function enqueueUnpackZip( payload: import("./unpack-zip").UnpackZipPayload ): Promise { diff --git a/src/lib/settings.ts b/src/lib/settings.ts index c7856e7..4d1b8e7 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -121,6 +121,21 @@ export const annSettingsSchema = z.object({ error: z.string().optional(), }) +/** + * Progress of the re-embed backfill that runs after the default embedding + * model changes (existing chunks are unusable for vector search until they + * are re-embedded with the new model). Operational state like `ai.ann`. + */ +export const reembedSettingsSchema = z.object({ + status: z.enum(["idle", "running", "done", "failed"]).default("idle"), + /** "providerId:modelId" the backfill (re-)embeds for. */ + embeddingModel: z.string().optional(), + total: z.number().int().nonnegative().optional(), + done: z.number().int().nonnegative().optional(), + failed: z.number().int().nonnegative().optional(), + error: z.string().optional(), +}) + const settingsSchemas = { "auth.registrationMode": registrationModeSchema, "auth.socialProviders": socialProvidersSchema, @@ -130,6 +145,7 @@ const settingsSchemas = { uploads: uploadsSchema, ai: aiSettingsSchema, "ai.ann": annSettingsSchema, + "ai.reembed": reembedSettingsSchema, "push.vapid": vapidSchema, "system.updateCheck": updateCheckSchema, } as const @@ -155,6 +171,7 @@ const defaults: { [K in SettingKey]: SettingValue } = { uploads: { maxUploadMb: 200, storageQuotaMbPerUser: 0 }, ai: { providers: [], monthlyTokenLimitPerUser: 0, useBatchApi: false }, "ai.ann": { status: "idle" }, + "ai.reembed": { status: "idle" }, "push.vapid": undefined as never, // generated on first use "system.updateCheck": undefined as never, // set once the first check has run } From 9c67e61e0e0f04bf3d8bcb0d2a8c8bab2558f66b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 09:22:15 +0000 Subject: [PATCH 4/4] feat(ai): citations, stop/regenerate, doc-chat, weak-topic generation, voice input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New AI features for v1.1: - clickable source citations in chat: search results carry an index and materialId, the model cites [n] inline, and deduped source chips below the answer deep-link to the material (persisted via tool parts) - stop button while streaming and one-click regenerate of the last answer; the server handles the AI SDK regenerate trigger by removing the trailing assistant rows (refused with 409 when an executed write tool is part of the answer — its side effect already exists) - AI-generated conversation titles after the first exchange (the 80-char truncation stays as instant placeholder) - tutor mode can now be switched mid-conversation (was fixed at creation) - 'chat with this document': aiConversation.materialId scopes retrieval to one material; entry button on the material viewer - weak-topics loop closed: the progress analysis returns a compact weak-topics list and offers one-click 'quiz/deck from my weak spots' - voice input in chat (MediaRecorder -> Whisper via configured OpenAI/Groq provider; hidden when unavailable) - 'Explain' on PDF highlights: extracts the highlighted region's text via the pdf.js text layer and shows an AI explanation in the annotation popover - optional LLM re-ranking of hybrid-search candidates (admin toggle, best-effort with RRF fallback) - user settings usage card now matches the enforced calendar-month window, shows utilization vs the monthly limit and a per-feature breakdown Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UrdkggYqhNCCZVHyJrdwBR --- drizzle/0037_typical_overlord.sql | 2 + drizzle/meta/0037_snapshot.json | 5834 +++++++++++++++++ drizzle/meta/_journal.json | 7 + messages/de.json | 32 +- messages/en.json | 32 +- src/app/[locale]/(app)/admin/ai/page.tsx | 2 +- .../(app)/ai/[conversationId]/page.tsx | 1 + src/app/[locale]/(app)/ai/actions.ts | 107 +- src/app/[locale]/(app)/learn-actions.ts | 12 +- .../[locale]/(app)/materials/[id]/page.tsx | 2 + src/app/[locale]/(app)/settings/page.tsx | 93 +- .../studies/[programId]/[moduleId]/page.tsx | 4 +- src/app/api/ai/chat/route.ts | 98 +- src/components/admin/ai-settings-form.tsx | 9 + src/components/ai/chat.tsx | 106 +- src/components/ai/conversation-panel.tsx | 30 +- src/components/ai/voice-input-button.tsx | 93 + src/components/learn/analyze-button.tsx | 71 +- .../materials/ask-document-button.tsx | 34 + src/components/materials/pdf-annotator.tsx | 84 +- src/db/schema/ai.ts | 8 + src/lib/ai/media.ts | 21 + src/lib/ai/rag.ts | 65 +- src/lib/settings.ts | 8 +- 24 files changed, 6697 insertions(+), 58 deletions(-) create mode 100644 drizzle/0037_typical_overlord.sql create mode 100644 drizzle/meta/0037_snapshot.json create mode 100644 src/components/ai/voice-input-button.tsx create mode 100644 src/components/materials/ask-document-button.tsx diff --git a/drizzle/0037_typical_overlord.sql b/drizzle/0037_typical_overlord.sql new file mode 100644 index 0000000..6769949 --- /dev/null +++ b/drizzle/0037_typical_overlord.sql @@ -0,0 +1,2 @@ +ALTER TABLE "ai_conversation" ADD COLUMN "material_id" text;--> statement-breakpoint +ALTER TABLE "ai_conversation" ADD CONSTRAINT "ai_conversation_material_id_material_id_fk" FOREIGN KEY ("material_id") REFERENCES "public"."material"("id") ON DELETE set null ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/meta/0037_snapshot.json b/drizzle/meta/0037_snapshot.json new file mode 100644 index 0000000..daade25 --- /dev/null +++ b/drizzle/meta/0037_snapshot.json @@ -0,0 +1,5834 @@ +{ + "id": "944c503f-f066-4ffd-8200-d67223d0a6d7", + "prevId": "646344f3-aa87-45b9-8f9f-a668339bee23", + "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 + }, + "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" + } + }, + "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.assessment_attempt": { + "name": "assessment_attempt", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "assessment_id": { + "name": "assessment_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": { + "assessment_attempt_assessmentId_idx": { + "name": "assessment_attempt_assessmentId_idx", + "columns": [ + { + "expression": "assessment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assessment_attempt_assessment_id_module_assessment_id_fk": { + "name": "assessment_attempt_assessment_id_module_assessment_id_fk", + "tableFrom": "assessment_attempt", + "tableTo": "module_assessment", + "columnsFrom": [ + "assessment_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.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_assessment": { + "name": "module_assessment", + "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, + "default": "'exam'" + }, + "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_assessment_moduleId_idx": { + "name": "module_assessment_moduleId_idx", + "columns": [ + { + "expression": "module_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "module_assessment_module_id_module_id_fk": { + "name": "module_assessment_module_id_module_id_fk", + "tableFrom": "module_assessment", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "module_assessment_module_id_unique": { + "name": "module_assessment_module_id_unique", + "nullsNotDistinct": false, + "columns": [ + "module_id" + ] + } + }, + "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.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 + }, + "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": {} + } + }, + "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" + } + }, + "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 + }, + "exam_type": { + "name": "exam_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'planned'" + }, + "is_thesis": { + "name": "is_thesis", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "bonus_type": { + "name": "bonus_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "bonus_value": { + "name": "bonus_value", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false + }, + "bonus_min_avg_percent": { + "name": "bonus_min_avg_percent", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false + }, + "bonus_min_completed_share": { + "name": "bonus_min_completed_share", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false + }, + "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.study_plan": { + "name": "study_plan", + "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": { + "study_plan_userId_idx": { + "name": "study_plan_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "study_plan_user_id_user_id_fk": { + "name": "study_plan_user_id_user_id_fk", + "tableFrom": "study_plan", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "study_plan_module_id_module_id_fk": { + "name": "study_plan_module_id_module_id_fk", + "tableFrom": "study_plan", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.study_plan_item": { + "name": "study_plan_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan_id": { + "name": "plan_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 + }, + "scheduled_date": { + "name": "scheduled_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "duration_minutes": { + "name": "duration_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "done": { + "name": "done", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "study_plan_item_planId_idx": { + "name": "study_plan_item_planId_idx", + "columns": [ + { + "expression": "plan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "study_plan_item_plan_id_study_plan_id_fk": { + "name": "study_plan_item_plan_id_study_plan_id_fk", + "tableFrom": "study_plan_item", + "tableTo": "study_plan", + "columnsFrom": [ + "plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.thesis_milestone": { + "name": "thesis_milestone", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "thesis_id": { + "name": "thesis_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": { + "thesis_milestone_thesisId_idx": { + "name": "thesis_milestone_thesisId_idx", + "columns": [ + { + "expression": "thesis_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "thesis_milestone_thesis_id_thesis_project_id_fk": { + "name": "thesis_milestone_thesis_id_thesis_project_id_fk", + "tableFrom": "thesis_milestone", + "tableTo": "thesis_project", + "columnsFrom": [ + "thesis_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.thesis_project": { + "name": "thesis_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 + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thesis_type": { + "name": "thesis_type", + "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": { + "thesis_project_userId_idx": { + "name": "thesis_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": "\"thesis_project\".\"superseded_by_id\" is null and \"thesis_project\".\"program_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "thesis_project_user_id_user_id_fk": { + "name": "thesis_project_user_id_user_id_fk", + "tableFrom": "thesis_project", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "thesis_project_program_id_degree_program_id_fk": { + "name": "thesis_project_program_id_degree_program_id_fk", + "tableFrom": "thesis_project", + "tableTo": "degree_program", + "columnsFrom": [ + "program_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "thesis_project_semester_id_semester_id_fk": { + "name": "thesis_project_semester_id_semester_id_fk", + "tableFrom": "thesis_project", + "tableTo": "semester", + "columnsFrom": [ + "semester_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "thesis_project_superseded_by_id_thesis_project_id_fk": { + "name": "thesis_project_superseded_by_id_thesis_project_id_fk", + "tableFrom": "thesis_project", + "tableTo": "thesis_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 + }, + "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.semester_plan_item": { + "name": "semester_plan_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignment_id": { + "name": "assignment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'study'" + }, + "title": { + "name": "title", + "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": false + }, + "duration_minutes": { + "name": "duration_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60 + }, + "done": { + "name": "done", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "semester_plan_item_plan_idx": { + "name": "semester_plan_item_plan_idx", + "columns": [ + { + "expression": "plan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "semester_plan_item_plan_id_semester_plan_id_fk": { + "name": "semester_plan_item_plan_id_semester_plan_id_fk", + "tableFrom": "semester_plan_item", + "tableTo": "semester_plan", + "columnsFrom": [ + "plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "semester_plan_item_module_id_module_id_fk": { + "name": "semester_plan_item_module_id_module_id_fk", + "tableFrom": "semester_plan_item", + "tableTo": "module", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "semester_plan_item_assignment_id_assignment_id_fk": { + "name": "semester_plan_item_assignment_id_assignment_id_fk", + "tableFrom": "semester_plan_item", + "tableTo": "assignment", + "columnsFrom": [ + "assignment_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 f8736ba..9830c70 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -260,6 +260,13 @@ "when": 1784365495581, "tag": "0036_motionless_rick_jones", "breakpoints": true + }, + { + "idx": 37, + "version": "7", + "when": 1784365763129, + "tag": "0037_typical_overlord", + "breakpoints": true } ] } \ No newline at end of file diff --git a/messages/de.json b/messages/de.json index 6b0033c..e54dea3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -211,7 +211,9 @@ "usageCalls": "Aufrufe", "testConnection": "Verbindung testen", "testOk": "Verbindung erfolgreich — der API-Key funktioniert.", - "testFailed": "Verbindungstest fehlgeschlagen: {error}" + "testFailed": "Verbindungstest fehlgeschlagen: {error}", + "rerank": "LLM-Reranking der Suche", + "rerankHint": "Sortiert Suchtreffer vor der Nutzung mit dem Standard-Modell neu — präzisere Ergebnisse, kostet einen kleinen Zusatzaufruf pro Suche." }, "email": { "title": "E-Mail (SMTP)", @@ -371,7 +373,9 @@ "input": "Input", "output": "Output", "limit": "Limit", - "unlimited": "unbegrenzt" + "unlimited": "unbegrenzt", + "utilization": "Auslastung (dieser Monat)", + "byFeature": "Nach Feature (dieser Monat)" }, "security": { "title": "Sicherheit", @@ -788,7 +792,9 @@ "annotator": { "hint": "Auf der Seite ziehen zum Markieren; Markierung anklicken für Notiz oder Löschen.", "notePlaceholder": "Notiz zu dieser Markierung …", - "fileMissing": "Die Datei fehlt auf dem Server — vermutlich ging sie bei einem früheren Redeploy verloren. Bitte lade sie neu hoch." + "fileMissing": "Die Datei fehlt auf dem Server — vermutlich ging sie bei einem früheren Redeploy verloren. Bitte lade sie neu hoch.", + "explain": "Erklären", + "explainEmpty": "In der Markierung wurde kein Text gefunden." }, "stats": { "files": "{count, plural, =1 {# Datei} other {# Dateien}}", @@ -825,7 +831,8 @@ "notIndexedHint": "Es ist kein Embedding-Modell konfiguriert — Materialien werden nicht für die AI-Suche indexiert. Ein Admin kann das unter Admin → AI einrichten.", "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." + "uploadServerError": "{name}: Serverfehler beim Speichern. Prüfe im Admin-Bereich, ob der Datei-Speicher beschreibbar ist.", + "askDocument": "Im Chat fragen" }, "search": { "modules": "Module", @@ -913,7 +920,17 @@ }, "errorGeneric": "Die Anfrage ist fehlgeschlagen. Bitte versuche es erneut.", "errorLimit": "Dein monatliches AI-Kontingent ist aufgebraucht.", - "errorAuth": "Der AI-Anbieter hat die Anfrage abgelehnt (API-Key ungültig oder abgelaufen). Bitte prüfe die AI-Einstellungen." + "errorAuth": "Der AI-Anbieter hat die Anfrage abgelehnt (API-Key ungültig oder abgelaufen). Bitte prüfe die AI-Einstellungen.", + "sources": "Quellen", + "regenerate": "Antwort neu generieren", + "stop": "Stoppen", + "modeLabel": "Modus", + "voice": { + "start": "Spracheingabe starten", + "stop": "Aufnahme beenden", + "empty": "Keine Sprache erkannt.", + "micDenied": "Mikrofonzugriff wurde verweigert." + } }, "learn": { "title": "Lernen", @@ -1228,7 +1245,10 @@ "analyzeTitle": "KI-Auswertung", "analyzing": "Analysiere deine Lernhistorie …", "capped": "Session mit {shown} — insgesamt {total} fällig", - "swipeHint": "Nach rechts wischen = Gut, nach links = Nochmal" + "swipeHint": "Nach rechts wischen = Gut, nach links = Nochmal", + "weakTopicsQuiz": "Quiz aus Schwachthemen", + "weakTopicsDeck": "Deck aus Schwachthemen", + "weakTopicsDeckName": "Schwachthemen-Wiederholung" }, "moduleTabs": { "overview": "Übersicht", diff --git a/messages/en.json b/messages/en.json index 63ed00c..a526be9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -211,7 +211,9 @@ "usageCalls": "Calls", "testConnection": "Test connection", "testOk": "Connection successful — the API key works.", - "testFailed": "Connection test failed: {error}" + "testFailed": "Connection test failed: {error}", + "rerank": "LLM re-ranking for search", + "rerankHint": "Re-orders search hits with the default model before use — more precise results at the cost of one small extra call per search." }, "email": { "title": "Email (SMTP)", @@ -371,7 +373,9 @@ "input": "Input", "output": "Output", "limit": "Limit", - "unlimited": "unlimited" + "unlimited": "unlimited", + "utilization": "Utilization (this month)", + "byFeature": "By feature (this month)" }, "security": { "title": "Security", @@ -788,7 +792,9 @@ "annotator": { "hint": "Drag on a page to highlight; click a highlight to add a note or delete it.", "notePlaceholder": "Note for this highlight …", - "fileMissing": "The file is missing on the server — it was probably lost in an earlier redeploy. Please upload it again." + "fileMissing": "The file is missing on the server — it was probably lost in an earlier redeploy. Please upload it again.", + "explain": "Explain", + "explainEmpty": "No text found in the highlighted area." }, "stats": { "files": "{count, plural, =1 {# file} other {# files}}", @@ -825,7 +831,8 @@ "notIndexedHint": "No embedding model is configured — materials are not indexed for AI search. An admin can set this up under Admin → AI.", "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." + "uploadServerError": "{name}: Server error while saving. Check in the admin area whether file storage is writable.", + "askDocument": "Ask in chat" }, "search": { "modules": "Modules", @@ -913,7 +920,17 @@ }, "errorGeneric": "The request failed. Please try again.", "errorLimit": "Your monthly AI quota is used up.", - "errorAuth": "The AI provider rejected the request (API key invalid or expired). Please check the AI settings." + "errorAuth": "The AI provider rejected the request (API key invalid or expired). Please check the AI settings.", + "sources": "Sources", + "regenerate": "Regenerate answer", + "stop": "Stop", + "modeLabel": "Mode", + "voice": { + "start": "Start voice input", + "stop": "Stop recording", + "empty": "No speech detected.", + "micDenied": "Microphone access was denied." + } }, "learn": { "title": "Learn", @@ -1228,7 +1245,10 @@ "analyzeTitle": "AI analysis", "analyzing": "Analyzing your learning history …", "capped": "session of {shown} — {total} due in total", - "swipeHint": "Swipe right = good, left = again" + "swipeHint": "Swipe right = good, left = again", + "weakTopicsQuiz": "Quiz from weak topics", + "weakTopicsDeck": "Deck from weak topics", + "weakTopicsDeckName": "Weak-topics review" }, "moduleTabs": { "overview": "Overview", diff --git a/src/app/[locale]/(app)/admin/ai/page.tsx b/src/app/[locale]/(app)/admin/ai/page.tsx index 29ec378..78501ab 100644 --- a/src/app/[locale]/(app)/admin/ai/page.tsx +++ b/src/app/[locale]/(app)/admin/ai/page.tsx @@ -50,7 +50,7 @@ export default async function AdminAiPage() { return (
diff --git a/src/app/[locale]/(app)/ai/actions.ts b/src/app/[locale]/(app)/ai/actions.ts index ff72fb3..093fbea 100644 --- a/src/app/[locale]/(app)/ai/actions.ts +++ b/src/app/[locale]/(app)/ai/actions.ts @@ -7,21 +7,123 @@ import { aiConversation } from "@/db/schema" import { requireSession } from "@/lib/auth/session" import { ownModule } from "@/lib/studies/access" -export async function createConversation(moduleId?: string | null, mode?: string) { +export async function createConversation( + moduleId?: string | null, + mode?: string, + materialId?: string | null +) { const session = await requireSession() if (moduleId) await ownModule(moduleId, session.user.id) + let scopedModuleId = moduleId ?? null + if (materialId) { + // "Chat with this document": verify ownership and inherit the module. + const { material } = await import("@/db/schema") + const row = await db.query.material.findFirst({ + where: and(eq(material.id, materialId), eq(material.userId, session.user.id)), + columns: { id: true, moduleId: true }, + }) + if (!row) throw new Error("Not found") + scopedModuleId = scopedModuleId ?? row.moduleId + } const { CHAT_MODES } = await import("@/lib/ai/modes") const safeMode = CHAT_MODES.includes(mode as (typeof CHAT_MODES)[number]) ? (mode as (typeof CHAT_MODES)[number]) : "general" const [created] = await db .insert(aiConversation) - .values({ userId: session.user.id, moduleId: moduleId ?? null, mode: safeMode }) + .values({ + userId: session.user.id, + moduleId: scopedModuleId, + materialId: materialId ?? null, + mode: safeMode, + }) .returning({ id: aiConversation.id }) revalidatePath("/ai") return { ok: true as const, id: created.id } } +/** Changes the tutor mode of an existing conversation. */ +export async function updateConversationMode(conversationId: string, mode: string) { + const session = await requireSession() + const { CHAT_MODES } = await import("@/lib/ai/modes") + if (!CHAT_MODES.includes(mode as (typeof CHAT_MODES)[number])) throw new Error("Unknown mode") + await db + .update(aiConversation) + .set({ mode }) + .where( + and(eq(aiConversation.id, conversationId), eq(aiConversation.userId, session.user.id)) + ) + revalidatePath("/ai") + return { ok: true as const } +} + +/** Whether voice input (speech-to-text) is available for this user. */ +export async function isVoiceInputAvailable(): Promise { + const session = await requireSession() + const { getTranscriptionModel } = await import("@/lib/ai/registry") + return Boolean(await getTranscriptionModel(session.user.id)) +} + +/** Transcribes a short voice recording from the chat input (max 10 MB). */ +export async function transcribeVoiceInput(formData: FormData) { + const session = await requireSession() + const file = formData.get("audio") + if (!(file instanceof Blob)) throw new Error("No audio") + if (file.size > 10 * 1024 * 1024) throw new Error("Recording too large") + const { assertWithinLimit } = await import("@/lib/ai/usage") + await assertWithinLimit(session.user.id) + const { transcribeAudioBuffer } = await import("@/lib/ai/media") + const text = await transcribeAudioBuffer( + new Uint8Array(await file.arrayBuffer()), + session.user.id + ) + return { text } +} + +/** Explains a highlighted passage from one of the user's materials. */ +export async function explainSnippet(materialId: string, snippet: string, context?: string) { + const session = await requireSession() + const cleanSnippet = snippet.trim().slice(0, 4000) + if (!cleanSnippet) throw new Error("Nothing selected") + const { material } = await import("@/db/schema") + const row = await db.query.material.findFirst({ + where: and(eq(material.id, materialId), eq(material.userId, session.user.id)), + columns: { id: true, name: true }, + }) + if (!row) throw new Error("Not found") + const { assertWithinLimit } = await import("@/lib/ai/usage") + 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") + const model = await getLanguageModel(modelRef, session.user.id) + const { generateText } = await import("ai") + const { GEN_PARAMS } = await import("@/lib/ai/params") + const { runAi } = await import("@/lib/ai/run") + const { text } = await runAi( + { + userId: session.user.id, + model: modelRef, + feature: "explain", + entityType: "material", + entityId: row.id, + entityLabel: row.name, + }, + () => + generateText({ + model, + ...GEN_PARAMS, + maxOutputTokens: 800, + prompt: `You are a study tutor. Explain the highlighted passage from the document "${row.name}" clearly and concisely for a university student: what it means, why it matters, and any term worth defining. Use Markdown, max ~150 words, answer in the language of the passage. + +Highlighted passage: +${cleanSnippet} +${context ? `\nSurrounding page text (context only):\n${context.slice(0, 4000)}` : ""}`, + }) + ) + return { explanation: text } +} + /** Load a conversation's messages (used by the floating quick chat). Returns null if not found. */ export async function getConversationMessages(conversationId: string) { const session = await requireSession() @@ -54,6 +156,7 @@ export async function listConversations() { title: c.title, moduleName: c.module?.name ?? null, moduleId: c.moduleId, + mode: c.mode, updatedAt: c.updatedAt.toISOString(), })) } diff --git a/src/app/[locale]/(app)/learn-actions.ts b/src/app/[locale]/(app)/learn-actions.ts index 0535092..b4dc9fb 100644 --- a/src/app/[locale]/(app)/learn-actions.ts +++ b/src/app/[locale]/(app)/learn-actions.ts @@ -380,5 +380,15 @@ Data (JSON): ${JSON.stringify(data).slice(0, 20000)}`, }) ) - return { ok: true as const, analysis: text } + // Compact weak-topic list (recent wrong questions + most-lapsed cards) so + // the UI can offer one-click "generate a review quiz/deck on my weak spots". + const wrongQuestions = [ + ...new Set( + data.quizzes.flatMap((q) => q.attempts.slice(0, 3).flatMap((a) => a.wrongQuestions)) + ), + ].slice(0, 20) + const lapsedFronts = data.problemFlashcards.slice(0, 10).map((c) => c.front) + const weakTopics = [...wrongQuestions, ...lapsedFronts].join("\n").slice(0, 1500) + + return { ok: true as const, analysis: text, weakTopics } } diff --git a/src/app/[locale]/(app)/materials/[id]/page.tsx b/src/app/[locale]/(app)/materials/[id]/page.tsx index 9297ea2..952fd4b 100644 --- a/src/app/[locale]/(app)/materials/[id]/page.tsx +++ b/src/app/[locale]/(app)/materials/[id]/page.tsx @@ -8,6 +8,7 @@ import { material, materialAnnotation } from "@/db/schema" import { requireSession } from "@/lib/auth/session" import { fileSize } from "@/lib/storage" import { Link } from "@/i18n/navigation" +import { AskDocumentButton } from "@/components/materials/ask-document-button" import { MediaPlayer } from "@/components/materials/media-player" import { PdfAnnotator } from "@/components/materials/pdf-annotator" import { Button } from "@/components/ui/button" @@ -65,6 +66,7 @@ export default async function MaterialViewerPage({

{row.name}

{row.module.name}

+ + )}
) )} @@ -348,9 +429,22 @@ export function Chat({ rows={2} className="max-h-40 flex-1 resize-none" /> - + setInput((v) => (v ? `${v} ${text}` : text))} + /> + {busy ? ( + // Streaming: the send slot becomes a stop button. Note the server + // deliberately drains the stream for crash-safe persistence, so the + // DB keeps the full answer; regenerate removes it if unwanted. + + ) : ( + + )}
diff --git a/src/components/ai/conversation-panel.tsx b/src/components/ai/conversation-panel.tsx index 7d939eb..dbef83b 100644 --- a/src/components/ai/conversation-panel.tsx +++ b/src/components/ai/conversation-panel.tsx @@ -22,11 +22,13 @@ import { getConversationMessages, listConversations, renameConversation, + updateConversationMode, updateConversationModule, } from "@/app/[locale]/(app)/ai/actions" import { Chat } from "@/components/ai/chat" import { usePageContext } from "@/components/ai/page-context" import { ModuleSelect, type ModuleOption } from "@/components/learn/module-select" +import { CHAT_MODES } from "@/lib/ai/modes" import { Button } from "@/components/ui/button" import { DropdownMenu, @@ -45,6 +47,8 @@ export type ConversationMeta = { title: string moduleName: string | null moduleId: string | null + /** Tutor mode; older callers may omit it — treated as "general". */ + mode?: string } /** @@ -125,6 +129,7 @@ export function ConversationPanel({ title: t("newConversation"), moduleId, moduleName: pageContext.moduleName ?? null, + mode: "general", } setConversations([meta, ...(list ?? conversations)]) setInitialMessages([]) @@ -209,6 +214,16 @@ export function ConversationPanel({ } } + async function onModeChange(mode: string) { + if (!current) return + try { + await updateConversationMode(current.id, mode) + setCurrent({ ...current, mode }) + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)) + } + } + async function onModuleChange(moduleId: string) { if (!current) return try { @@ -347,13 +362,26 @@ export function ConversationPanel({ onConfirm={onDelete} /> -
+
{t("module")} void onModuleChange(v)} /> + {t("modeLabel")} +
diff --git a/src/components/ai/voice-input-button.tsx b/src/components/ai/voice-input-button.tsx new file mode 100644 index 0000000..7ed3d8b --- /dev/null +++ b/src/components/ai/voice-input-button.tsx @@ -0,0 +1,93 @@ +"use client" + +import * as React from "react" +import { Loader2, Mic, Square } from "lucide-react" +import { useTranslations } from "next-intl" +import { toast } from "sonner" +import { Button } from "@/components/ui/button" +import { isVoiceInputAvailable, transcribeVoiceInput } from "@/app/[locale]/(app)/ai/actions" + +/** + * Push-to-talk voice input: records via MediaRecorder, transcribes server-side + * (Whisper via the configured OpenAI/Groq provider) and hands the text to the + * caller. Renders nothing when the browser can't record or no transcription + * provider is configured. + */ +export function VoiceInputButton({ + disabled, + onTranscript, +}: { + disabled?: boolean + onTranscript: (text: string) => void +}) { + const t = useTranslations("ai.voice") + const [available, setAvailable] = React.useState(false) + const [recording, setRecording] = React.useState(false) + const [transcribing, setTranscribing] = React.useState(false) + const recorderRef = React.useRef(null) + + React.useEffect(() => { + if (typeof MediaRecorder === "undefined" || !navigator.mediaDevices?.getUserMedia) return + isVoiceInputAvailable() + .then(setAvailable) + .catch(() => setAvailable(false)) + }, []) + + React.useEffect(() => { + return () => recorderRef.current?.stream.getTracks().forEach((track) => track.stop()) + }, []) + + async function start() { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) + const recorder = new MediaRecorder(stream) + const chunks: Blob[] = [] + recorder.ondataavailable = (e) => { + if (e.data.size > 0) chunks.push(e.data) + } + recorder.onstop = async () => { + stream.getTracks().forEach((track) => track.stop()) + setRecording(false) + const blob = new Blob(chunks, { type: recorder.mimeType || "audio/webm" }) + if (blob.size === 0) return + setTranscribing(true) + try { + const formData = new FormData() + formData.append("audio", blob, "voice-input.webm") + const { text } = await transcribeVoiceInput(formData) + if (text) onTranscript(text) + else toast.error(t("empty")) + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)) + } finally { + setTranscribing(false) + } + } + recorderRef.current = recorder + recorder.start() + setRecording(true) + } catch { + toast.error(t("micDenied")) + } + } + + if (!available) return null + + return ( + + ) +} diff --git a/src/components/learn/analyze-button.tsx b/src/components/learn/analyze-button.tsx index b538eb4..83241f0 100644 --- a/src/components/learn/analyze-button.tsx +++ b/src/components/learn/analyze-button.tsx @@ -1,10 +1,13 @@ "use client" import * as React from "react" -import { Loader2, Sparkles } from "lucide-react" +import { BrainCircuit, Layers, Loader2, Sparkles } from "lucide-react" import { useTranslations } from "next-intl" import { toast } from "sonner" +import { useRouter } from "@/i18n/navigation" import { analyzeProgress } from "@/app/[locale]/(app)/learn-actions" +import { createDeck, generateCards } from "@/app/[locale]/(app)/deck-actions" +import { generateQuiz } from "@/app/[locale]/(app)/quiz-actions" import { Markdown } from "@/components/ai/markdown" import { Button } from "@/components/ui/button" import { @@ -17,15 +20,22 @@ import { /** "What should I deepen?" — AI analysis over the module's full session history. */ export function AnalyzeButton({ moduleId, + basePath, variant = "outline", }: { moduleId: string + /** Module base path (/studies/[programId]/[moduleId]) for the one-click + * weak-topics generation links; omitting hides those buttons. */ + basePath?: string variant?: "outline" | "default" }) { const t = useTranslations("learnSession") + const router = useRouter() const [open, setOpen] = React.useState(false) const [pending, setPending] = React.useState(false) const [analysis, setAnalysis] = React.useState(null) + const [weakTopics, setWeakTopics] = React.useState("") + const [generating, setGenerating] = React.useState<"quiz" | "deck" | null>(null) async function run() { setOpen(true) @@ -34,6 +44,7 @@ export function AnalyzeButton({ try { const result = await analyzeProgress(moduleId) setAnalysis(result.analysis) + setWeakTopics(result.weakTopics ?? "") } catch (error) { toast.error(error instanceof Error ? error.message : String(error)) setOpen(false) @@ -42,6 +53,30 @@ export function AnalyzeButton({ } } + async function makeQuiz() { + setGenerating("quiz") + try { + const result = await generateQuiz({ moduleId, count: 10, topics: weakTopics, mixed: true }) + router.push(`${basePath}/quizzes/${result.id}`) + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)) + setGenerating(null) + } + } + + async function makeDeck() { + setGenerating("deck") + try { + const created = await createDeck({ name: t("weakTopicsDeckName"), moduleId }) + await generateCards({ deckId: created.id, count: 15, topics: weakTopics }) + router.push(`${basePath}/decks/${created.id}`) + return + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)) + setGenerating(null) + } + } + return ( <> + +
+ )} +
) : null} diff --git a/src/components/materials/ask-document-button.tsx b/src/components/materials/ask-document-button.tsx new file mode 100644 index 0000000..4396129 --- /dev/null +++ b/src/components/materials/ask-document-button.tsx @@ -0,0 +1,34 @@ +"use client" + +import * as React from "react" +import { Loader2, MessageSquare } from "lucide-react" +import { useTranslations } from "next-intl" +import { toast } from "sonner" +import { useRouter } from "@/i18n/navigation" +import { Button } from "@/components/ui/button" +import { createConversation } from "@/app/[locale]/(app)/ai/actions" + +/** Starts a document-scoped AI conversation ("chat with this document"). */ +export function AskDocumentButton({ materialId }: { materialId: string }) { + const t = useTranslations("materials") + const router = useRouter() + const [pending, setPending] = React.useState(false) + + async function start() { + setPending(true) + try { + const { id } = await createConversation(null, "general", materialId) + router.push(`/ai/${id}`) + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)) + setPending(false) + } + } + + return ( + + ) +} diff --git a/src/components/materials/pdf-annotator.tsx b/src/components/materials/pdf-annotator.tsx index 16b3b7f..52baea9 100644 --- a/src/components/materials/pdf-annotator.tsx +++ b/src/components/materials/pdf-annotator.tsx @@ -1,7 +1,7 @@ "use client" import * as React from "react" -import { Loader2, Trash2 } from "lucide-react" +import { Loader2, Sparkles, Trash2 } from "lucide-react" import { useTranslations } from "next-intl" import { toast } from "sonner" import { @@ -9,7 +9,9 @@ import { deleteAnnotation, updateAnnotationNote, } from "@/app/[locale]/(app)/materials-actions" +import { explainSnippet } from "@/app/[locale]/(app)/ai/actions" import type { AnnotationColor, AnnotationRect } from "@/db/schema/materials" +import { Markdown } from "@/components/ai/markdown" import { Button } from "@/components/ui/button" import { Textarea } from "@/components/ui/textarea" import { cn } from "@/lib/utils" @@ -133,6 +135,67 @@ export function PdfAnnotator({ } } + const [explaining, setExplaining] = React.useState(false) + const [explanation, setExplanation] = React.useState(null) + + /** Text of the page region covered by an annotation (pdf.js text layer). */ + async function extractRectText(annotation: PdfAnnotation): Promise<{ + snippet: string + pageText: string + }> { + const pdf = pdfRef.current as { + getPage: (n: number) => Promise<{ + getViewport: (o: { scale: number }) => { width: number; height: number } + getTextContent: () => Promise<{ + items: { str?: string; transform?: number[]; width?: number; height?: number }[] + }> + }> + } | null + if (!pdf) return { snippet: "", pageText: "" } + const page = await pdf.getPage(annotation.page) + const viewport = page.getViewport({ scale: 1 }) + const content = await page.getTextContent() + const parts: string[] = [] + const all: string[] = [] + const r = annotation.rect + for (const item of content.items) { + if (!item.str || !item.transform) continue + all.push(item.str) + // transform[4]/[5] are the item's origin in PDF units (bottom-left based); + // annotation rects are normalized with y from the top. + const cx = (item.transform[4] + (item.width ?? 0) / 2) / viewport.width + const cy = 1 - item.transform[5] / viewport.height + const pad = 0.01 + if ( + cx >= r.x - pad && + cx <= r.x + r.w + pad && + cy >= r.y - pad && + cy <= r.y + r.h + pad + ) { + parts.push(item.str) + } + } + return { snippet: parts.join(" ").trim(), pageText: all.join(" ").trim() } + } + + async function onExplain() { + if (!selected) return + setExplaining(true) + setExplanation(null) + try { + const { snippet, pageText } = await extractRectText(selected) + // A rect over a diagram may cover no text — fall back to the note or page. + const effective = snippet || selected.note || pageText.slice(0, 1000) + if (!effective) throw new Error(t("explainEmpty")) + const result = await explainSnippet(materialId, effective, pageText) + setExplanation(result.explanation) + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)) + } finally { + setExplaining(false) + } + } + if (error) { return

{error}

} @@ -177,6 +240,7 @@ export function PdfAnnotator({ onSelect={(a) => { setSelected(a) setNote(a.note ?? "") + setExplanation(null) }} /> ))} @@ -184,6 +248,11 @@ export function PdfAnnotator({ {selected && (
+ {explanation && ( +
+ {explanation} +
+ )}