From 5bf6013460fbd4b30d8a7f8fee3da0938c469fe2 Mon Sep 17 00:00:00 2001 From: Arnaud Nelissen Date: Wed, 8 Jul 2026 22:08:14 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20full=20feature=20buildout=20=E2=80=94?= =?UTF-8?q?=20streaming,=20i18n,=20mastery=20map,=20admin,=20jobs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Progressive lesson streaming via onSegment callback (fixes SSE for non-English users — locale was shadowed in lesson-reader useEffect). Adds: BullMQ workers, Redis stream buffer, token budget enforcement, Langfuse tracing, golden-eval runner, Playwright e2e scaffolding, lesson depth/locale/preferences schema, mastery map UI, admin panel (blueprints/users/reports/quality/misconceptions), image queries, source citations, view transitions, reading animations, i18n (next-intl), PDF export, surprise endpoint, and 402 passing unit tests. Co-Authored-By: Claude Sonnet 4.6 --- docs/implementation-plan.md | 329 +++ docs/theme-map-plan.md | 70 + drizzle/migrations/0006_slim_ultragirl.sql | 1 + drizzle/migrations/0007_lesson_locale.sql | 1 + drizzle/migrations/0008_noisy_cloak.sql | 1 + drizzle/migrations/0009_unique_wallflower.sql | 13 + drizzle/migrations/0010_lesson_depth.sql | 1 + drizzle/migrations/0011_smooth_thanos.sql | 1 + drizzle/migrations/0012_user_preferences.sql | 2 + drizzle/migrations/0013_illegal_random.sql | 2 + drizzle/migrations/0014_faulty_namor.sql | 2 + drizzle/migrations/0015_mighty_prowler.sql | 12 + drizzle/migrations/0016_theme_map.sql | 30 + .../migrations/0017_married_betty_brant.sql | 17 + drizzle/migrations/meta/0006_snapshot.json | 1480 ++++++++++++++ drizzle/migrations/meta/0008_snapshot.json | 1487 ++++++++++++++ drizzle/migrations/meta/0009_snapshot.json | 1574 +++++++++++++++ drizzle/migrations/meta/0011_snapshot.json | 1581 +++++++++++++++ drizzle/migrations/meta/0013_snapshot.json | 1595 +++++++++++++++ drizzle/migrations/meta/0014_snapshot.json | 1608 +++++++++++++++ drizzle/migrations/meta/0015_snapshot.json | 1685 ++++++++++++++++ drizzle/migrations/meta/0017_snapshot.json | 1797 +++++++++++++++++ drizzle/migrations/meta/_journal.json | 84 + jobs/bootstrap.ts | 30 + jobs/generate-lesson-job.ts | 74 +- jobs/index.ts | 4 +- jobs/promote-blueprint-job.ts | 2 +- jobs/regenerate-blueprint-job.ts | 165 ++ jobs/server-only.noop.cjs | 5 + messages/en.json | 586 ++++++ messages/fr.json | 586 ++++++ next.config.ts | 9 +- package.json | 11 +- pnpm-lock.yaml | 1754 +++++++++++++++- pnpm-workspace.yaml | 2 + .../[id]/blueprint-detail-client.tsx | 223 ++ src/app/admin/blueprints/[id]/page.tsx | 101 + .../admin/blueprints/blueprints-client.tsx | 164 +- src/app/admin/invites/invites-client.tsx | 186 ++ src/app/admin/invites/page.tsx | 24 + src/app/admin/layout.tsx | 3 + .../misconceptions/misconceptions-client.tsx | 227 +++ src/app/admin/misconceptions/page.tsx | 9 + src/app/admin/quality/page.tsx | 202 ++ src/app/admin/reports/page.tsx | 24 +- src/app/admin/reports/reports-client.tsx | 29 +- src/app/admin/settings/settings-client.tsx | 2 +- src/app/admin/users/page.tsx | 4 +- src/app/admin/users/users-client.tsx | 48 +- src/app/api/admin/blueprints/[id]/route.ts | 61 +- .../api/admin/blueprints/[id]/verify/route.ts | 29 + src/app/api/admin/blueprints/route.ts | 29 +- src/app/api/admin/content-status/route.ts | 33 + src/app/api/admin/invites/[id]/route.ts | 13 + .../api/admin/invites/__tests__/route.test.ts | 82 + src/app/api/admin/invites/route.ts | 69 + src/app/api/admin/misconceptions/route.ts | 72 + .../admin/users/[id]/__tests__/route.test.ts | 16 + src/app/api/admin/users/[id]/route.ts | 13 +- src/app/api/advance/__tests__/route.test.ts | 61 +- src/app/api/advance/route.ts | 84 +- .../accept-invite/__tests__/route.test.ts | 110 + src/app/api/auth/accept-invite/route.ts | 72 + .../migrate-session/__tests__/route.test.ts | 37 +- src/app/api/auth/migrate-session/route.ts | 52 +- .../review-digest/__tests__/route.test.ts | 138 ++ src/app/api/cron/review-digest/route.ts | 94 + src/app/api/grade/__tests__/route.test.ts | 1 + src/app/api/grade/route.ts | 2 + .../api/home-summary/__tests__/route.test.ts | 80 + src/app/api/home-summary/route.ts | 50 + src/app/api/image/route.ts | 68 + src/app/api/intent/route.ts | 3 +- src/app/api/lessons/__tests__/route.test.ts | 5 +- src/app/api/lessons/pdf/route.ts | 53 + src/app/api/lessons/route.ts | 55 +- .../lessons/stream/__tests__/route.test.ts | 104 + src/app/api/lessons/stream/route.ts | 80 + src/app/api/mastery/__tests__/route.test.ts | 4 + src/app/api/mastery/route.ts | 45 +- src/app/api/preferences/route.ts | 21 + src/app/api/review/__tests__/route.test.ts | 1 + src/app/api/review/route.ts | 10 +- src/app/api/surprise/route.ts | 96 + src/app/api/themes/route.ts | 15 + src/app/api/unsubscribe/route.ts | 39 + src/app/api/user/locale/route.ts | 37 + src/app/auth/accept-invite/page.tsx | 116 ++ src/app/auth/error/page.tsx | 29 +- src/app/auth/forgot-password/page.tsx | 22 +- src/app/auth/login/login-client.tsx | 45 +- src/app/auth/register/page.tsx | 27 +- src/app/auth/reset-password/page.tsx | 20 +- src/app/auth/verify-email/page.tsx | 12 +- src/app/globals.css | 2 + src/app/layout.tsx | 34 +- src/app/learn/[key]/page.tsx | 49 +- src/app/map/map-client.tsx | 347 ++++ src/app/map/page.tsx | 10 + src/app/onboarding/page.tsx | 229 +++ src/app/page.tsx | 311 ++- src/app/profile/page.tsx | 102 +- src/app/settings/page.tsx | 5 +- src/app/settings/settings-client.tsx | 416 ++-- src/auth.ts | 23 +- src/components/__tests__/mastery-map.test.ts | 114 ++ .../__tests__/use-checkpoint-grading.test.ts | 177 ++ src/components/checkpoint-input.tsx | 34 +- src/components/grade-display.tsx | 132 +- src/components/lesson-reader.tsx | 544 ++--- src/components/mastery-map.tsx | 284 ++- src/components/migrate-on-auth.tsx | 35 + src/components/review-reader.tsx | 291 +-- src/components/segment-image.tsx | 74 + src/components/site-header.tsx | 96 +- src/components/use-checkpoint-grading.ts | 140 ++ src/i18n/config.ts | 7 + src/i18n/request.ts | 13 + src/lib/__tests__/theme.test.ts | 63 + src/lib/cache/__tests__/lesson.test.ts | 46 +- src/lib/cache/__tests__/stream.test.ts | 75 + src/lib/cache/lesson.ts | 32 +- src/lib/cache/stream.ts | 87 + src/lib/db/__tests__/digest.test.ts | 96 + src/lib/db/__tests__/journey.test.ts | 175 ++ src/lib/db/queries.ts | 684 ++++++- src/lib/db/schema.ts | 65 + src/lib/email/__tests__/review-digest.test.ts | 66 + src/lib/email/templates.ts | 58 + .../__tests__/ensure-lesson.test.ts | 218 ++ .../__tests__/generate-lesson.test.ts | 14 +- .../__tests__/novel-misconception.test.ts | 84 + .../__tests__/quality-metrics.test.ts | 117 ++ .../__tests__/regenerate-job.test.ts | 46 + .../generation/__tests__/staleness.test.ts | 56 + src/lib/generation/depth.ts | 50 + src/lib/generation/ensure-lesson.ts | 117 ++ src/lib/generation/generate-blueprint.ts | 64 +- src/lib/generation/generate-lesson.ts | 85 +- src/lib/generation/golden-cases.ts | 52 + src/lib/generation/retrieval.ts | 15 +- src/lib/generation/staleness.ts | 90 + src/lib/jobs/queue.ts | 32 + src/lib/llm/client.ts | 7 +- src/lib/llm/prompts/index.ts | 50 +- src/lib/llm/repair.ts | 27 + src/lib/memory/__tests__/spaced-rep.test.ts | 22 +- src/lib/memory/bkt.ts | 4 +- src/lib/memory/spaced-rep.ts | 18 +- src/lib/pdf/lesson-pdf.tsx | 245 +++ src/lib/theme.ts | 26 + src/lib/verification/grader.ts | 16 +- src/schemas/api.ts | 10 + src/schemas/generation.ts | 44 +- src/schemas/preferences.ts | 23 + src/styles/app.css | 427 +++- src/styles/reading.css | 1185 ++++++++++- src/styles/settings.css | 275 +++ src/styles/tokens.css | 5 +- tests/golden/run-grading-eval.ts | 19 + tsconfig.tsbuildinfo | 2 +- 161 files changed, 27152 insertions(+), 1161 deletions(-) create mode 100644 docs/implementation-plan.md create mode 100644 docs/theme-map-plan.md create mode 100644 drizzle/migrations/0006_slim_ultragirl.sql create mode 100644 drizzle/migrations/0007_lesson_locale.sql create mode 100644 drizzle/migrations/0008_noisy_cloak.sql create mode 100644 drizzle/migrations/0009_unique_wallflower.sql create mode 100644 drizzle/migrations/0010_lesson_depth.sql create mode 100644 drizzle/migrations/0011_smooth_thanos.sql create mode 100644 drizzle/migrations/0012_user_preferences.sql create mode 100644 drizzle/migrations/0013_illegal_random.sql create mode 100644 drizzle/migrations/0014_faulty_namor.sql create mode 100644 drizzle/migrations/0015_mighty_prowler.sql create mode 100644 drizzle/migrations/0016_theme_map.sql create mode 100644 drizzle/migrations/0017_married_betty_brant.sql create mode 100644 drizzle/migrations/meta/0006_snapshot.json create mode 100644 drizzle/migrations/meta/0008_snapshot.json create mode 100644 drizzle/migrations/meta/0009_snapshot.json create mode 100644 drizzle/migrations/meta/0011_snapshot.json create mode 100644 drizzle/migrations/meta/0013_snapshot.json create mode 100644 drizzle/migrations/meta/0014_snapshot.json create mode 100644 drizzle/migrations/meta/0015_snapshot.json create mode 100644 drizzle/migrations/meta/0017_snapshot.json create mode 100644 jobs/bootstrap.ts create mode 100644 jobs/regenerate-blueprint-job.ts create mode 100644 jobs/server-only.noop.cjs create mode 100644 messages/en.json create mode 100644 messages/fr.json create mode 100644 src/app/admin/blueprints/[id]/blueprint-detail-client.tsx create mode 100644 src/app/admin/blueprints/[id]/page.tsx create mode 100644 src/app/admin/invites/invites-client.tsx create mode 100644 src/app/admin/invites/page.tsx create mode 100644 src/app/admin/misconceptions/misconceptions-client.tsx create mode 100644 src/app/admin/misconceptions/page.tsx create mode 100644 src/app/admin/quality/page.tsx create mode 100644 src/app/api/admin/blueprints/[id]/verify/route.ts create mode 100644 src/app/api/admin/content-status/route.ts create mode 100644 src/app/api/admin/invites/[id]/route.ts create mode 100644 src/app/api/admin/invites/__tests__/route.test.ts create mode 100644 src/app/api/admin/invites/route.ts create mode 100644 src/app/api/admin/misconceptions/route.ts create mode 100644 src/app/api/auth/accept-invite/__tests__/route.test.ts create mode 100644 src/app/api/auth/accept-invite/route.ts create mode 100644 src/app/api/cron/review-digest/__tests__/route.test.ts create mode 100644 src/app/api/cron/review-digest/route.ts create mode 100644 src/app/api/home-summary/__tests__/route.test.ts create mode 100644 src/app/api/home-summary/route.ts create mode 100644 src/app/api/image/route.ts create mode 100644 src/app/api/lessons/pdf/route.ts create mode 100644 src/app/api/lessons/stream/__tests__/route.test.ts create mode 100644 src/app/api/lessons/stream/route.ts create mode 100644 src/app/api/preferences/route.ts create mode 100644 src/app/api/surprise/route.ts create mode 100644 src/app/api/themes/route.ts create mode 100644 src/app/api/unsubscribe/route.ts create mode 100644 src/app/api/user/locale/route.ts create mode 100644 src/app/auth/accept-invite/page.tsx create mode 100644 src/app/map/map-client.tsx create mode 100644 src/app/map/page.tsx create mode 100644 src/app/onboarding/page.tsx create mode 100644 src/components/__tests__/mastery-map.test.ts create mode 100644 src/components/__tests__/use-checkpoint-grading.test.ts create mode 100644 src/components/migrate-on-auth.tsx create mode 100644 src/components/segment-image.tsx create mode 100644 src/components/use-checkpoint-grading.ts create mode 100644 src/i18n/config.ts create mode 100644 src/i18n/request.ts create mode 100644 src/lib/__tests__/theme.test.ts create mode 100644 src/lib/cache/__tests__/stream.test.ts create mode 100644 src/lib/cache/stream.ts create mode 100644 src/lib/db/__tests__/digest.test.ts create mode 100644 src/lib/db/__tests__/journey.test.ts create mode 100644 src/lib/email/__tests__/review-digest.test.ts create mode 100644 src/lib/generation/__tests__/ensure-lesson.test.ts create mode 100644 src/lib/generation/__tests__/novel-misconception.test.ts create mode 100644 src/lib/generation/__tests__/quality-metrics.test.ts create mode 100644 src/lib/generation/__tests__/regenerate-job.test.ts create mode 100644 src/lib/generation/__tests__/staleness.test.ts create mode 100644 src/lib/generation/depth.ts create mode 100644 src/lib/generation/ensure-lesson.ts create mode 100644 src/lib/generation/golden-cases.ts create mode 100644 src/lib/generation/staleness.ts create mode 100644 src/lib/llm/repair.ts create mode 100644 src/lib/pdf/lesson-pdf.tsx create mode 100644 src/lib/theme.ts create mode 100644 src/schemas/preferences.ts create mode 100644 src/styles/settings.css diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md new file mode 100644 index 0000000..a049826 --- /dev/null +++ b/docs/implementation-plan.md @@ -0,0 +1,329 @@ +# Curio — Implementation Plan (12 features) + +Audience: **Claude Sonnet**, executing one feature at a time. Each section is self-contained: goal, verified current state (with file refs), changes, tests, and acceptance criteria. + +This plan was written after mapping the codebase on 2026-06-23. **Verify file:line refs before editing — code may have moved.** + +## Global conventions (apply to every feature — non-negotiable) + +These come from `CLAUDE.md` and the spec. Violating them is a bug: + +1. **All LLM calls go through `src/lib/llm/client.ts`.** Never call a provider/AI-SDK model directly. Generator and grader must differ. +2. **Prompts live in the versioned registry** `src/lib/llm/prompts/index.ts` (`{ id, version, template }`). Never inline a prompt. Bump `version` on any semantic change. +3. **Contracts are Zod schemas** in `src/schemas/`, shared by LLM ↔ API ↔ UI. Validate LLM structured output against them. +4. **Serve path reads the buffer; jobs generate.** No synchronous generation in request handlers except the documented cold-start gate. Postgres is source of truth. +5. **Paid background jobs are idempotent** (idempotency key + durable `jobs` ledger row). A retry must never regenerate-and-rebill. +6. **Per-session token budget is enforced in code** (`src/lib/budget/index.ts`) — call `checkBudget` before any new paid LLM call, `recordUsage` after. +7. **Migrations are append-only.** Never edit an applied migration. Add a new one via `pnpm db:generate` then `pnpm db:migrate`. (Note: the migrations dir already contains a few accidental duplicate migrations — do not "fix" them, just add new ones.) +8. **i18n: every user-facing string goes in BOTH `messages/en.json` and `messages/fr.json`** under the right namespace, read via `useTranslations` (client) or `getTranslations` (server). No hardcoded copy. +9. **Tests with every change.** Unit tests mock the LLM (Vitest, deterministic fixtures). Real-model calls only in `tests/golden/`. Run `pnpm test` before declaring done. +10. **Design quality floor:** responsive, visible keyboard focus, reduced-motion respected, WCAG-AA contrast, dark-mode tokens. Reading surface stays calm/editorial; no gamification. + +Stack reference: Next.js 15 App Router + React 19, Drizzle + Postgres/pgvector, Redis (ioredis + Upstash ratelimit), BullMQ workers (`jobs/`, run via `pnpm workers`), Vercel AI SDK, next-auth v5 (JWT), next-intl, nodemailer (SMTP), Langfuse. Vitest + Playwright. + +--- + +## Suggested execution order + +Dependency-aware. Do them in this order unless told otherwise: + +1. **#7 (audit migration)** — small, de-risks the account story others build on. +2. **#10 (dark-mode polish)** — small, isolated; extracts the `setTheme` helper #13 reuses. +3. **#13 (settings upgrade)** — needs #10's `setTheme` helper; otherwise self-contained UI. +4. **#12 (review upgrade)** — extracts the shared grading hook from the lesson reader. +5. **#2 (content versioning)** — foundational; #1 and #3 lean on version/provenance. +6. **#1 (novel-misconception harvest loop)** — moat; table already exists. +7. **#3 (false-fail dashboard)** — consumes #1's labeled data + grades. +8. **#6 (multi-lesson journey)** — product; #5 and #11's due-zone build on journey/mastery state. +9. **#11 (mastery upgrade)** — surfaces due reviews; pairs with #6/#5. +10. **#5 (returning-learner home)** — product; needs #6's journey state. +11. **#9 (streaming generation)** — infra polish. +12. **#8 (due-review email digest)** — retention; independent, do last. + +UI features (#11/#12/#13) are loosely ordered — they can move earlier if a UI pass is wanted sooner; only #13→#10 (theme helper) and #12→lesson-reader (shared hook) are hard dependencies. + +Each feature is one focused PR/commit. Keep Route Handlers thin; logic in `src/lib/`. + +--- + +## #7 — Anonymous → account progress migration (AUDIT + HARDEN) + +**Status: already implemented.** This is an audit/harden task, NOT a build. + +**Current state (verified):** +- `src/app/api/auth/migrate-session/route.ts` exists. In a transaction it reassigns `responses`, `mastery` (with on-conflict skip when the auth user already has that concept), and `journeyInstances` from `anonUserId` → `authUserId`, then renames the Redis `budget:{id}` key. Has `__tests__/`. +- Called from `src/app/auth/login/login-client.tsx` and `src/app/auth/register/page.tsx` after auth success; clears `sessionStorage` `curio_uid`. + +**Goal:** make it correct and complete, not bigger. + +**Changes / checks:** +1. **Mastery conflict policy.** Today conflicting concepts are *skipped* (anon progress dropped). Decide intended behavior: keep the higher `score` / more-recent `lastSeen` instead of silently dropping. If changing, do it in the `onConflictDoUpdate` and document why. +2. **Auth coverage.** Confirm migration fires for ALL sign-in paths: credentials login, register, AND OAuth (Google/GitHub/OIDC) first sign-in. OAuth users never hit `login-client.tsx`. Add a migration trigger on the OAuth callback path (e.g. a client effect on first authenticated landing, or a `signIn` event in `src/auth.ts` events) — without it, OAuth users lose anon progress. +3. **Idempotency / double-call.** Calling migrate twice (or with no anon data) must be a no-op, not an error. Verify. +4. **Validation & authz.** The route must take `anonUserId` from the request but `authUserId` strictly from the server session (never trust a client-supplied authUserId). Confirm a malicious `anonUserId` can only move data INTO the caller's own account. +5. **`accounts`/`sessions` tables** must not be touched by migration (those are auth-adapter owned). + +**Tests:** extend `migrate-session/__tests__`: (a) conflict resolution keeps best mastery; (b) OAuth-path migration; (c) double-call no-op; (d) authz — session id always wins over body. + +**Acceptance:** A user who learns anonymously, then signs up OR logs in via any provider, keeps all mastery/responses/journey. No data loss on concept conflicts. `pnpm test` green. + +--- + +## #10 — Dark mode (AUDIT + POLISH) + +**Status: already implemented.** Audit/polish task. + +**Current state (verified):** +- `src/styles/tokens.css` has a full dark token block under `:root[data-theme='dark']` plus a `@media (prefers-color-scheme: dark)` fallback for `:root:not([data-theme='light'])`. +- `src/app/layout.tsx` runs a no-flash theme bootstrap (reads `localStorage('curio-theme')`, sets `data-theme` on ``). +- `src/components/site-header.tsx` has a `ThemeToggle` cycling system → dark → light. + +**Goal:** verified-correct dark mode on every surface + a settings entry. + +**Changes:** +1. **Settings selector.** Add a theme control to `src/app/settings/settings-client.tsx` (System / Light / Dark) that writes the same `localStorage('curio-theme')` key + `data-theme` the header toggle uses (share one helper — extract `setTheme()` to e.g. `src/lib/theme.ts`). Add i18n keys under `settings.appearance.*` in both locales. +2. **Contrast audit (WCAG-AA, spec §7.5 non-negotiable).** Verify every diagnosis-state color in dark mode meets AA against its background: `--color-mastered/partial/misconception/uncertain` and their `-subtle` pairs, plus `--color-tutor` marginalia and accent on `--color-surface`. Fix any token that fails. This is the highest-risk part — the diagnosis colors carry meaning. +3. **Surface sweep.** Manually verify dark rendering on: home, learn/reading surface + marginalia, checkpoint input, review, mastery map, auth pages, admin, settings. Look for hardcoded colors (e.g. `#fff`) that ignore tokens — `grep -rn "#fff\|#000\|rgb(" src/app src/components src/styles` and replace with tokens. +4. **Persisted preference (optional).** If a user is logged in, persist theme in `users.preferences` (jsonb already exists) so it follows them across devices; localStorage remains the anon/no-flash source. + +**Tests:** unit test the `setTheme` helper. Playwright: toggle to dark, assert `data-theme="dark"` on `` and a known token's computed value. + +**Acceptance:** Theme switchable from header AND settings; persists; no flash on reload; all diagnosis colors pass AA in both themes; no hardcoded non-token colors remain on core surfaces. + +--- + +## #2 — Content versioning + lazy invalidation + +**Goal:** when a blueprint's generator model or prompt version changes, mark its content stale and regenerate lazily (on next serve or via a job), without regenerate-and-rebill races. Spec §10 / invariant #10. + +**Current state (verified):** +- `blueprints.modelVersion` (text) + `blueprints.contentVersion` (int, default 1). `segments.contentVersion` + `checkpoints.contentVersion` exist but are **never incremented** and **never read** (`generate-blueprint.ts:~85` writes `modelVersion = process.env.LLM_GENERATOR_MODEL`, `contentVersion = 1`). +- Prompt registry has versions (e.g. `GENERATE_LESSON` is v5) but nothing ties a blueprint to the prompt version it was built with. +- `jobs/promote-blueprint-job.ts` already invalidates the Redis cache after promotion — reuse that invalidation primitive. + +**Define "current content signature":** a tuple `(generatorModel, generateLessonPromptVersion)`. Persist what each blueprint was built with; compare against current at serve/audit time. + +**Changes:** +1. **Schema migration:** add `blueprints.promptVersion` (int) and `blueprints.staleAt` (timestamp, nullable). Optionally `blueprints.lastBuiltModel` if `modelVersion` semantics are ambiguous. Generate via `pnpm db:generate`. +2. **Stamp on generation:** in `src/lib/generation/generate-lesson.ts` / `generate-blueprint.ts`, write the current `prompts.GENERATE_LESSON.version` and generator model id at build time. +3. **Staleness check:** add `src/lib/generation/staleness.ts` exporting `isBlueprintStale(blueprint): boolean` (compares stored model+promptVersion to current config) and `markStale(blueprintId)`. +4. **Invalidation trigger:** add an admin action (button on `src/app/admin/blueprints/[id]`) + an internal function to mark a blueprint stale and bump `contentVersion`. On the serve path (`getLessonResponse` in `src/lib/db/queries.ts` / `src/app/learn/[key]/page.tsx`), if stale, enqueue a regeneration job (do NOT regenerate synchronously) and keep serving the old version until the new one is ready (lazy invalidation — spec invariant #10). +5. **Regeneration job:** add `jobs/regenerate-blueprint-job.ts` (or extend `generate-lesson-job`) that regenerates segments/checkpoints at a new `contentVersion`, re-runs T0/T1 (and T2 at promotion), and swaps the cache. **Idempotency key must include the target `contentVersion`** so a retry never double-bills. Reuse the `jobs` ledger pattern from `generate-lesson-job.ts`. +6. **Flagged-item regeneration:** `autoFlagCheckpointIfNeeded` (already in `queries.ts`) sets a checkpoint to `verifyStatus='fail'`. Add an endpoint/admin action to regenerate just the flagged checkpoint at a bumped `contentVersion` (cheaper than whole-blueprint). + +**Tests:** unit (LLM mocked): `isBlueprintStale` truth table; regeneration job bumps `contentVersion` and is idempotent under retry (same key → no second generation); serve path enqueues but does not block on stale. + +**Acceptance:** Changing `LLM_GENERATOR_MODEL` or bumping the lesson prompt version causes new serves to enqueue regeneration; learners keep seeing valid old content until the new version verifies; retried jobs never double-generate; admin can force-regenerate a blueprint or a single flagged checkpoint. + +--- + +## #1 — Novel-misconception harvest loop + +**Goal:** close the moat flywheel — turn `novel`-tagged grades into labeled misconceptions that grow the misconception library AND the golden set. Spec §10.2 / §10.4 / §15. + +**Current state (verified):** +- `grades.misconceptionTag` can be `'novel'`. `src/lib/verification/grader.ts:~183` already enqueues novel cases into the `novelMisconductQueue` table (`{ gradeId, checkpointId, responseText, evidenceSpan, processed:boolean default false, createdAt }`, schema `~line 268`). +- `getPersonalizationContext` (`queries.ts:~394`) already excludes `'novel'` from personalization (only labeled misconceptions are used). So once a novel case is *labeled* into `misconceptions`, it automatically starts being used. +- There is **no UI to review the queue, no labeling action, and no path from a labeled item into `tests/golden/misconception-cases.ts`.** That's the gap. + +**Changes:** +1. **Queries** (`src/lib/db/queries.ts`): `getNovelQueue({ processed:false, limit })` returning queue rows joined with checkpoint + concept context; `labelNovelMisconception({ queueId, conceptId, tag, signature, description, correction })` which (a) inserts a row into `misconceptions` with `verifyStatus='pending'`, (b) marks the queue row `processed=true`, in one transaction; `dismissNovelQueueItem(queueId)` for false positives. +2. **Admin page** `src/app/admin/misconceptions/` (new): list unprocessed queue items showing the learner response with the `evidenceSpan` highlighted (reuse the marginalia highlight idiom from `grade-display.tsx`), the checkpoint prompt, reference answer, and existing misconceptions for that concept. Provide a label form (tag, signature, description, correction) and a dismiss button. Add to admin nav (`src/app/admin/layout.tsx`). +3. **Verification hook:** after labeling, the new misconception is `verifyStatus='pending'`. Run it through the existing `verifyMisconceptions` (`src/lib/verification/verify-misconceptions.ts`) — either inline on label or via a small job — so only verified misconceptions go live. +4. **Golden-set growth:** add `appendMisconceptionGoldenCase()` that writes a new case to `tests/golden/misconception-cases.ts` (or a sibling JSON the runner reads). At minimum, the admin label action should offer "also add as golden case" producing `{ response, expectedTag, conceptId }`. Keep the golden file the source of truth for the eval. +5. **Optional clustering:** if the queue is large, add a cheap-model job to cluster similar novel responses (group by embedding similarity of `responseText`) so a human labels a cluster once. Use `client.cheapGrader` / `embedText`. Defer if time-boxed. + +**Tests:** unit (LLM mocked): labeling inserts misconception + flips `processed` atomically; dismiss leaves no misconception; labeled+verified misconception appears in `getPersonalizationContext` for that concept. Add ≥2 new cases to `misconception-cases.ts` and confirm `pnpm test:golden:misconceptions` still passes thresholds. + +**Acceptance:** An admin can see novel responses, label or dismiss them; labeled items become verified misconceptions used in future grading/personalization; the golden set grows; the queue drains (`processed=true`). No new uncontrolled LLM cost on the serve path. + +--- + +## #3 — False-fail monitoring dashboard + +**Goal:** make the spec's core quality metric — false-fail rate (telling a correct learner they're wrong) — continuously visible, not just a CI gate. Spec §15. + +**Current state (verified):** +- `tests/golden/run-grading-eval.ts` computes false-fail rate and verdict accuracy against frozen cases; CI-gated (`pnpm test:golden`, fails if false-fail > 10% or accuracy < 70%). **Writes no persistent results** (console only). +- Live signal exists but isn't aggregated: `grades` (verdict, confidence, payloadJson), `contentReports` with `reason='grade_wrong'` (learner-disputed grades), `getCheckpointFailureRate` + `autoFlagCheckpointIfNeeded` in `queries.ts`. +- `src/app/admin/reports/` lists raw content reports; no metrics/aggregation. + +**Two data sources, show both:** +- **Offline (golden):** trend of false-fail rate / accuracy per eval run. +- **Online (production):** `grade_wrong` report rate vs total grades; misconception/uncertain verdict distribution; checkpoints auto-flagged. + +**Changes:** +1. **Persist golden runs:** new table `eval_runs` (`id, suite, falseFailRate, accuracy, total, model, promptVersion, gitSha nullable, createdAt`). Make `run-grading-eval.ts` (and the content/misconception runners) insert a row at the end (guard behind an env flag so local runs can skip DB writes). Keep console output. +2. **Aggregation queries** (`src/lib/db/queries.ts` or `src/lib/metrics.ts`): `getEvalTrend(suite, limit)`; `getOnlineGradeMetrics(window)` → counts by verdict, `grade_wrong` report rate, # auto-flagged checkpoints, optionally per-blueprint. +3. **Admin dashboard** `src/app/admin/quality/` (new; link in admin nav): show latest false-fail rate with the 10% threshold marked (red if breached), a small trend (sparkline/table) from `eval_runs`, the online verdict distribution, and a list of checkpoints with the highest dispute/failure rate (drill into `/admin/blueprints/[id]`). Keep it calm and tabular — admin tool, not the learner surface. +4. **Alerting (optional):** if the latest persisted false-fail rate breaches threshold, surface a banner on `/admin`. + +**Tests:** unit: aggregation queries over seeded grades/reports return correct counts and rates; `eval_runs` insert is gated by env flag. No need to run real models in unit tests. + +**Acceptance:** After a golden run, the admin quality page shows the latest + historical false-fail rate against the threshold; production grade/dispute metrics are visible; high-dispute checkpoints are findable. False-fail is now observable continuously. + +--- + +## #6 — Multi-lesson journey continuity + +**Goal:** a Journey is a *sequence* of bounded lessons through a blueprint, not a single lesson. Let a learner finish lesson N and continue to N+1, with visible progress. Spec §1, §3. + +**Current state (verified):** +- `blueprints` → `concepts` (ord) → `lessons` (ord, depth, locale) → `segments` → `checkpoints`. `journeyInstances.position` is incremented by `advanceJourneyPosition` (`queries.ts:~675`) on lesson complete, but **nothing reads position** and there is **no next-lesson chaining**. `/learn/[key]` serves one lesson per intent (`getLessonResponse`). +- `LessonComplete` in `lesson-reader.tsx` calls `POST /api/advance` then shows "Progress saved" — a dead end. + +**Decision to confirm with product:** does one blueprint contain multiple `lessons` (multi-lesson per intent), or is each intent one lesson and a "journey" spans related blueprints? The schema supports multiple `lessons` per blueprint (`lessons.ord`), so **default assumption: multiple lessons per blueprint, ordered by `lessons.ord`.** Generation currently makes one lesson — extending generation to produce a multi-lesson arc is a larger content change; for this feature, support *serving and navigating* multiple lessons and generate subsequent lessons lazily on advance. + +**Changes:** +1. **Serve by position:** extend `getLessonResponse` (or add `getLessonAtPosition(intentKey, userId, position, ...)`) to return the lesson at the learner's current `journeyInstances.position` (default 0 → first lesson by `lessons.ord`). `/learn/[key]/page.tsx` reads the user's position to pick the lesson. +2. **Advance → next:** on lesson complete, after `advanceJourneyPosition`, if a next lesson exists (or can be generated), navigate to it (e.g. `/learn/[key]?pos=N` or just re-fetch since position advanced). If the next lesson isn't generated yet, enqueue `generate-lesson-job` for `(blueprintId, ord=position)` and show the outline/poll state (reuse existing polling). If no next lesson and the blueprint is complete, show a real "Journey complete" terminal state (not just "saved"). +3. **Lesson generation for position N:** `generate-lesson.ts` / `generate-lesson-job.ts` currently assume ord 0. Parameterize by `ord`/`position` and pass the concept(s) for that position. Idempotency key must include `ord`. +4. **Progress UI:** add a journey progress indicator to the reading surface header (e.g. "Lesson 2 of 5") sourced from `lessons` count + position. Calm, non-gamified (spec §7). i18n both locales. +5. **Cross-check #5:** the returning-home "continue" surface will read the same `journeyInstances` + position. + +**Tests:** unit (LLM mocked): position increments serve the next lesson; terminal state when position ≥ lesson count; advance enqueues generation for an ungenerated next lesson with an ord-scoped idempotency key. Playwright: complete a lesson → land on the next or a real completion screen. + +**Acceptance:** Completing a lesson advances to the next lesson in the blueprint (generating it lazily if needed) with a visible "Lesson N of M"; finishing the last lesson shows a Journey-complete state; position persists per user. No synchronous generation on the serve path. + +--- + +## #5 — Returning-learner home + +**Goal:** give the second visit a story — resume in-progress journeys, see due reviews, glance at mastery — instead of only an intent box. Spec §16 (mastery map, Daily Review). + +**Current state (verified):** +- `src/app/page.tsx` is intent entry only (title, lede, input, depth/mode, example chips, "spark"). No resume surface. +- Data already available: `journeyInstances` (position) — after #6 this is meaningful; `getMasteryMap(userId)`; `getReviewCheckpoints(userId)` / `getDueReviews`. Anonymous users have a `curio_uid`; logged-in users have `session.user.id`. + +**Changes:** +1. **Detect "returning":** a learner is "returning" if they have any `journeyInstances`, `mastery`, or due reviews. For anon users this requires the client `curio_uid`; the home page is a client component already, so fetch on mount (or add a `GET /api/home-summary?userId=` that returns `{ inProgress: [{intentKey, title, position, total}], dueReviewCount, recentMastery: [...] }`). For logged-in users, prefer the session id. +2. **New endpoint** `src/app/api/home-summary/route.ts`: aggregates in-progress journeys (join `journeyInstances` → `blueprints`, compute position/total), `dueReviewCount` (from `getReviewCheckpoints`), and a small recent-mastery slice. Reuse existing queries; add a `getInProgressJourneys(userId)` query. +3. **Home UI:** when summary is non-empty, render (above or beside the intent box) a calm "Continue" section: resume cards linking to `/learn/[intentKey]` (which #6 resolves to the right lesson via position), a "Review (N due)" link to `/review` when `dueReviewCount > 0`, and a "Mastery" link. When empty (first visit), show today's intent-first layout unchanged. Keep editorial calm; no streaks/XP (spec anti-pattern). +4. **i18n:** `home.continue.*`, `home.dueReviews`, etc., both locales. +5. **Loading/empty states:** skeleton while summary loads; never block the intent box on the summary fetch (intent entry must always be instant). + +**Tests:** unit: `getInProgressJourneys` + home-summary aggregation over seeded data; empty summary → first-visit layout. Playwright: a user with progress sees Continue cards + due-review link; a fresh user sees only the intent entry. + +**Acceptance:** Returning learners (anon or logged-in) land on a home that surfaces in-progress journeys, due-review count, and mastery, each one click away; first-time visitors see the existing clean intent entry; the intent box is never delayed by the summary. + +--- + +## #9 — Streaming generation (skeleton-first) + +**Goal:** replace the 4-second poll with true streamed delivery so a cold-start lesson appears progressively. Spec §8.3 (skeleton-first + buffer). + +**Current state (verified):** +- Cold start returns an `outline` state; `src/components/lesson-reader.tsx:~97` polls `router.refresh()` every 4000ms until the lesson is `ready`. Generation runs in `jobs/generate-lesson-job.ts` (BullMQ), warming the Redis buffer. +- Vercel AI SDK supports streaming; `client.ts` uses structured output (`generateObject`-style). Lesson generation is schema-constrained, which complicates token-streaming of structured output. + +**Approach (pick the lower-risk one that fits):** +- **Option A — Stream segment-by-segment from the buffer (recommended).** Keep generation in the job, but as each segment is generated + T0/T1-verified, push it to the Redis buffer (`buffer:{...}` per spec §17) and expose `GET /api/lessons/stream?key=...` as an SSE endpoint that emits segments as they land. `LessonReader` consumes the SSE and appends segments, replacing the poll. This honors invariant #4 (serve reads buffer, jobs generate) and degrades to the existing poll if SSE drops. +- **Option B — Stream the model directly** via `streamObject` for the reading text only (checkpoints still structured). More premium feel but riskier with schema validation + provenance (`source_chunk_ids`) + verification. Only if Option A is insufficient. + +**Changes (Option A):** +1. **Buffer incrementally:** in `generate-lesson-job.ts`, write each verified segment to the Redis buffer as it completes (not only the whole lesson at the end). Keep the final whole-lesson cache write for the fast path. +2. **SSE route** `src/app/api/lessons/stream/route.ts`: Node runtime (not edge — needs timeout headroom), reads the buffer, emits `segment` events as they appear, a `ready` event when complete, and closes. Respect the per-session budget only for the generation job, not the read. +3. **Client:** in `lesson-reader.tsx`, when in outline/cold-start state, open an `EventSource` to the stream and append segments with the existing View-Transition reveal; fall back to the 4s poll if `EventSource` errors or is unsupported. Remove the poll once `ready`. +4. **Reduced motion:** segment-in animation must respect `prefers-reduced-motion` (already handled in `reading.css`). + +**Tests:** unit: buffer write-per-segment ordering; SSE route emits segments then `ready` (mock the buffer). Playwright: cold-start lesson shows segments appearing without a full reload. + +**Acceptance:** A cold-start lesson streams in segment-by-segment instead of popping after a 4s poll; falls back gracefully to polling; no synchronous generation on the request path; reduced-motion respected. + +--- + +## #8 — Due-review email digest + +**Goal:** bring learners back when reviews are due — the spaced-repetition retention loop. Email infra already exists; only transactional sends today. + +**Current state (verified):** +- `src/lib/email/index.ts` `sendEmail({to,subject,html,text?})` over nodemailer SMTP (dev: jsonTransport→console). `src/lib/email/templates.ts` has verification/invite/reset templates. **No scheduled sending.** +- `src/lib/memory/spaced-rep.ts` (`isDue`, `scheduleNextReview`) + `getReviewCheckpoints(userId)` / `getDueReviews` give due items. `mastery.nextReview` is the schedule. Only logged-in users have an email (`users.email`); anon users can't be emailed. + +**Changes:** +1. **Digest template:** add `reviewDigestEmail({ name, dueCount, topConcepts, reviewUrl })` to `templates.ts` (+ both-locale subject/body via the user's `users.locale`). Calm, on the learner's side; one clear CTA to `/review`. No gamified streak language. +2. **Recipient query** (`src/lib/db/queries.ts`): `getUsersWithDueReviews(now)` → users with `email IS NOT NULL`, `emailVerified`, ≥1 mastery row where `nextReview <= now`, with a due count + a few concept names. Respect an opt-out preference (see 4). +3. **Scheduled job:** add a BullMQ repeatable job (or a cron-invoked Route Handler protected by a secret header, e.g. for Vercel Cron) `jobs/review-digest-job.ts` that runs daily, fetches due users, and sends at most one digest per user per day. **Idempotency:** key on `(userId, yyyy-mm-dd)` via the `jobs` ledger so a re-run never double-sends. Throttle/batch sends. +4. **Opt-out:** add `emailDigest: boolean` (default true) to `users.preferences` (jsonb exists) with a toggle in `settings-client.tsx` (+ i18n). Include an unsubscribe link (signed token route) in the email — required for good email hygiene. +5. **Config:** guard the whole feature behind an env flag (e.g. `REVIEW_DIGEST_ENABLED`) and document SMTP + cron setup in `.env.example` / `docs`. + +**Tests:** unit (mock `sendEmail`): `getUsersWithDueReviews` filters correctly (no email / unverified / opted-out excluded); digest job sends once per user per day (idempotency key blocks re-send); unsubscribe flips the preference. Do not send real email in tests. + +**Acceptance:** A daily job emails verified, opted-in users who have reviews due, at most once/day, idempotently, with a working unsubscribe and locale-correct copy; feature is env-gated and documented; anon users are never emailed. + +--- + +## #11 — Mastery page upgrade + +**Goal:** turn the mastery page from a flat card grid into a calm "state of your learning" dashboard, and surface the actionable thing (due reviews). Spec §16 (mastery map). Stays editorial, non-gamified (spec §7). + +**Current state (verified):** +- `src/components/mastery-map.tsx` — title + per-blueprint groups of `ConceptCard`s (name, score bar, %, a small "due" tag). Data from `GET /api/mastery?userId=`. `src/app/mastery/page.tsx` just renders ``. Styles live in `src/styles/reading.css` under `.mastery-*`. +- **BUG (fix as part of this):** `mastery-map.tsx:39-43` `scoreLabel()` returns **hardcoded French** ("Maîtrisé/En cours/À revoir") in an i18n app — EN users see French. Also "Mastery Map", "Loading…", "due", and the empty-state string are hardcoded English. None go through `useTranslations`. + +**Changes:** +1. **Localize everything first (bug fix).** Move all strings to `messages/en.json` + `fr.json` under a `mastery.*` namespace (`title`, `loading`, `empty`, `due`, `status.mastered|learning|needsWork`, overview labels). Read via `useTranslations('mastery')`. No hardcoded copy. +2. **Overview header.** Above the groups, a summary strip: counts of `mastered / learning / needs-work` and one progress meter (mastered ÷ total). Derive client-side from the fetched groups. Calm, tabular — not a gamified dashboard. +3. **Due-for-review zone.** Pull concepts where `nextReview <= now` into a top "Due for review (N)" section with a direct **Start review →** link to `/review`. Today "due" is a buried tag; it's the actionable item. +4. **Sort + optional filter.** Within each blueprint, order concepts needs-work → learning → mastered so weak spots rise. Optional filter chips (All / Due / Needs work) reusing the `.home-chip` pill idiom from `app.css`. +5. **Card polish.** Add marginalia status border-left using existing `--color-mastered / --color-tutor / --color-misconception`; demote the raw `%` to secondary; let the bar carry the signal. Keep the existing `role="meter"` a11y. +6. **Empty state with CTA** → link to home, not a dead sentence. + +**Tests:** unit: `scoreStatus`/label mapping localized (no French leakage in `en`); overview counts correct over seeded groups; due-zone includes only `nextReview <= now`. Playwright: EN locale shows English status labels; due concept shows in the due zone with a working review link. + +**Acceptance:** Mastery page opens with a counts overview + a meter, a prominent due-review zone linking to `/review`, status-ordered cards, and fully localized copy (no French for EN users). Passes the design floor in both themes. + +--- + +## #12 — Review page upgrade + +**Goal:** make Daily Review diagnose as richly as a lesson and make the mastery movement visible — it's the whole point of reviewing. Spec §11 (memory engine), §4 (tutoring loop). + +**Current state (verified):** +- `src/components/review-reader.tsx` — sequential carousel (`currentIdx`), per-item `ScoreBar`, `CheckpointInput`, `GradeDisplay`, Next/Finish. Data from `GET /api/review?userId=`. +- **Degraded vs lessons:** `review-reader.tsx:~162` renders `GradeDisplay` WITHOUT `onSocraticSubmit`, `onExplain`, or the `document.startViewTransition` considering→reveal beat that `lesson-reader.tsx` uses. So partial verdicts in review have no Socratic probe, no re-explain, no motion. +- Completion is a flat "Session complete / scores updated". + +**Changes:** +1. **Restore full diagnosis (parity with lesson).** Wire the same handlers `lesson-reader.tsx` uses: Socratic follow-up (`/api/socratic`), re-explain (`/api/explain`), and wrap the grade reveal in `document.startViewTransition` with the considering beat. Factor the shared grade-handling out of `lesson-reader.tsx` into a hook (e.g. `src/components/use-checkpoint-grading.ts`) so both readers share one implementation instead of diverging again. +2. **Visible mastery delta.** After grading, animate the `ScoreBar` from the pre-grade score to the new score (fetch/return updated mastery from the grade response or recompute) — e.g. "62% → 71% ↑". This is the payoff of review. +3. **Session summary.** Replace the terminal state with a real recap: concepts reviewed, net mastery change, count still due / next-due hint. Quiet, not a score screen. +4. **Progress bar.** Thin top bar with one segment per item filled as you go, replacing/augmenting the "N of M" text. +5. **Keyboard flow.** Cmd/Ctrl+Enter to submit, Enter to advance to next. Review is repetitive — keyboard makes it fast. Respect existing a11y/focus. + +**Tests:** unit: shared grading hook drives both readers (partial verdict in review now exposes Socratic probe); delta computed correctly; summary aggregates per session. Playwright: complete a review item with a partial verdict → Socratic probe appears; finishing shows the recap with a net delta. + +**Acceptance:** Review supports Socratic probe + re-explain + the reveal beat exactly like lessons (shared code path), shows the score moving per item, and ends on a meaningful session recap. Reduced-motion respected. + +--- + +## #13 — Settings page upgrade + +**Goal:** bring Settings in line with the app's design system (tokens + CSS file, not inline styles), make it navigable, and reuse existing control idioms. + +**Current state (verified):** +- `src/app/settings/settings-client.tsx` — one long stacked column: Profile, Language, Learning (age/difficulty/depth/hint), Security, Danger. +- **DEBT (fix as part of this):** the entire page is **inline-styled** via a `const s = {}` object (`~lines 281-372`), with hardcoded `#fff` on buttons (`~354`, `~366`) and hardcoded error strings ("Failed to save.", "Linked: …", "Failed."). Violates the token-centralization convention (CLAUDE.md design invariants) and diverges from the home/reading idiom. Uses raw OS radios where home has polished segmented controls. + +**Changes:** +1. **De-inline (debt fix).** Move all styles to a stylesheet (`src/styles/settings.css`, imported in `globals.css`, or extend `app.css`) using design tokens. Remove the `s` object and every `#fff` (→ `--color-surface`). This also makes the page dark-mode-correct. +2. **Localize stray strings.** "Failed to save.", "Linked:", "Failed." → `settings.*` i18n keys in both locales. +3. **Two-column layout (desktop).** Left = section nav (Profile / Learning / Language / Appearance / Security / Danger) as anchor links or tabs; right = panels. Single column on mobile. The page is long enough to warrant structure. +4. **Reuse the segmented-control idiom.** Replace the raw radio groups (age / difficulty / depth) with the home `.home-depth-options`-style pill segments for visual consistency. Keep them real `
`/radio semantics for a11y. +5. **Add the Appearance section (ties to #10).** Theme selector (System / Light / Dark) sharing the extracted `setTheme` helper from `src/lib/theme.ts`. i18n `settings.appearance.*`. +6. **Tighten the danger zone.** Keep the type-your-email confirm; make the destructive button visually distinct (already uses `--color-misconception`) and the warning unmissable. + +**Tests:** unit: locale change / preference save handlers unchanged and still call the right endpoints; no inline-style regressions (snapshot or lint rule if one exists). Playwright: section nav scrolls/switches panels; theme selector flips `data-theme`; settings render correctly in dark mode. + +**Acceptance:** Settings uses tokens via a CSS file (no inline `s` object, no `#fff`), is navigable on desktop and stacks on mobile, reuses the segmented-control look, includes a working theme selector, and is fully localized — visually consistent with home and the reading surface in both themes. + +--- + +## Notes for the executor + +- **Confirm before large content changes.** #6's "generate a multi-lesson arc" and #2's regeneration touch paid generation — keep them idempotent and budget-checked, and surface the design choice (multi-lesson-per-blueprint assumption) before building if anything contradicts it. +- **Don't regress golden evals.** #1 and #3 touch the grading/eval path. Run `pnpm test:golden` before/after; add cases, never weaken thresholds. +- **Each feature: update `CLAUDE.md` if a convention changes, in the same commit.** +- **Keep the reading surface calm.** Every UI addition (#3 dashboard, #5 home, #6 progress) must pass the design floor: responsive, AA contrast in both themes, visible focus, reduced-motion, no gamification. + + diff --git a/docs/theme-map-plan.md b/docs/theme-map-plan.md new file mode 100644 index 0000000..5aa3e63 --- /dev/null +++ b/docs/theme-map-plan.md @@ -0,0 +1,70 @@ +# Theme Map — Implementation Plan + +## Goal +A visual theme map that lets users discover and start lessons by topic domain. Entry flow B: clicking a theme pre-fills the home intent input, keeping the intent-driven flow intact. + +## Fixed taxonomy (12 themes, stable slugs) + +| Slug | Label (FR) | Label (EN) | Emoji | +|---|---|---|---| +| `sciences-naturelles` | Sciences naturelles | Natural sciences | 🔬 | +| `mathematiques` | Mathématiques | Mathematics | ∑ | +| `histoire` | Histoire | History | 📜 | +| `geographie` | Géographie | Geography | 🌍 | +| `langues-litterature` | Langues & littérature | Languages & literature | 📖 | +| `arts-culture` | Arts & culture | Arts & culture | 🎨 | +| `technologie-informatique` | Technologie | Technology | 💻 | +| `philosophie-ethique` | Philosophie & éthique | Philosophy & ethics | 💭 | +| `economie-societe` | Économie & société | Economics & society | 📊 | +| `sante-medecine` | Santé & médecine | Health & medicine | 🏥 | +| `sport-activite` | Sport & activité | Sport & activity | ⚡ | +| `autre` | Autre | Other | ✦ | + +## Phases + +### Phase 1 — Data model +- `theme` table: `id (uuid pk), slug (unique), label_en, label_fr, emoji` +- `blueprint_theme` join: `blueprint_id → blueprint, theme_id → theme` (composite pk) +- Seed `theme` rows in migration (fixed taxonomy, never regenerated) +- Add `themes: string[]` to `BlueprintContentSchema` Zod schema +- Bump `GENERATE_BLUEPRINT` to v3 — instruct LLM to pick 1–3 slugs from taxonomy +- Update `generate-blueprint.ts` — after blueprint insert, upsert `blueprint_theme` rows + +### Phase 2 — Static cluster view (`/map`) +- New page `/map` — grid of theme cards +- Each card: emoji + label + count of user's blueprints in theme +- Click card → `/?theme=` (home page with pre-filled context) +- No graph lib needed — pure CSS + +### Phase 3 — Home page theme pre-fill +- Read `useSearchParams().get('theme')` on home page +- If present: pre-fill the intent input placeholder and initial value with a localised prompt + e.g. `?theme=sciences-naturelles` → input shows "En savoir plus sur les sciences naturelles…" +- Clear the param after submission + +### Phase 4 — Graph view +- Toggle on `/map`: list view ↔ graph view +- SVG-based radial layout — 12 theme nodes arranged in a circle, no physics +- Blueprint sub-nodes orbit each theme (smaller dots) +- Node color = mastery aggregate for that theme (green / amber / grey) +- Click node → `/?theme=` +- Pure SVG + CSS transitions, no animation library + +## Adjacency (hardcoded, for SVG edges) +Related themes share an edge in the graph: +- sciences-naturelles ↔ mathematiques, geographie, sante-medecine, technologie-informatique +- mathematiques ↔ technologie-informatique, economie-societe +- histoire ↔ geographie, philosophie-ethique, arts-culture, langues-litterature +- geographie ↔ economie-societe, sport-activite +- langues-litterature ↔ arts-culture, philosophie-ethique +- arts-culture ↔ philosophie-ethique +- technologie-informatique ↔ economie-societe +- philosophie-ethique ↔ economie-societe +- sante-medecine ↔ sport-activite + +## Invariants respected +- No new LLM provider — theme assignment uses same generator model via client.ts +- Theme prompt is versioned in the registry +- Blueprint generation stays synchronous (themes saved in same transaction) +- No animation library — SVG + CSS transitions only +- Mobile responsive — graph falls back to list on small screens diff --git a/drizzle/migrations/0006_slim_ultragirl.sql b/drizzle/migrations/0006_slim_ultragirl.sql new file mode 100644 index 0000000..60c3534 --- /dev/null +++ b/drizzle/migrations/0006_slim_ultragirl.sql @@ -0,0 +1 @@ +ALTER TABLE "user" ADD COLUMN "locale" varchar(10) DEFAULT 'en' NOT NULL; \ No newline at end of file diff --git a/drizzle/migrations/0007_lesson_locale.sql b/drizzle/migrations/0007_lesson_locale.sql new file mode 100644 index 0000000..41a985f --- /dev/null +++ b/drizzle/migrations/0007_lesson_locale.sql @@ -0,0 +1 @@ +ALTER TABLE "lesson" ADD COLUMN "locale" varchar(10) DEFAULT 'en' NOT NULL; diff --git a/drizzle/migrations/0008_noisy_cloak.sql b/drizzle/migrations/0008_noisy_cloak.sql new file mode 100644 index 0000000..5135b82 --- /dev/null +++ b/drizzle/migrations/0008_noisy_cloak.sql @@ -0,0 +1 @@ +ALTER TABLE "lesson" ADD COLUMN "locale" varchar(10) DEFAULT 'en' NOT NULL; \ No newline at end of file diff --git a/drizzle/migrations/0009_unique_wallflower.sql b/drizzle/migrations/0009_unique_wallflower.sql new file mode 100644 index 0000000..4c6999d --- /dev/null +++ b/drizzle/migrations/0009_unique_wallflower.sql @@ -0,0 +1,13 @@ +CREATE TABLE "invite" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "token" text NOT NULL, + "email" varchar(255), + "role" "user_role" DEFAULT 'learner' NOT NULL, + "invited_by" uuid NOT NULL, + "expires" timestamp NOT NULL, + "accepted_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "invite_token_unique" UNIQUE("token") +); +--> statement-breakpoint +ALTER TABLE "invite" ADD CONSTRAINT "invite_invited_by_user_id_fk" FOREIGN KEY ("invited_by") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/migrations/0010_lesson_depth.sql b/drizzle/migrations/0010_lesson_depth.sql new file mode 100644 index 0000000..011e409 --- /dev/null +++ b/drizzle/migrations/0010_lesson_depth.sql @@ -0,0 +1 @@ +ALTER TABLE "lesson" ADD COLUMN "depth" varchar(20) DEFAULT 'standard' NOT NULL; \ No newline at end of file diff --git a/drizzle/migrations/0011_smooth_thanos.sql b/drizzle/migrations/0011_smooth_thanos.sql new file mode 100644 index 0000000..011e409 --- /dev/null +++ b/drizzle/migrations/0011_smooth_thanos.sql @@ -0,0 +1 @@ +ALTER TABLE "lesson" ADD COLUMN "depth" varchar(20) DEFAULT 'standard' NOT NULL; \ No newline at end of file diff --git a/drizzle/migrations/0012_user_preferences.sql b/drizzle/migrations/0012_user_preferences.sql new file mode 100644 index 0000000..37ae351 --- /dev/null +++ b/drizzle/migrations/0012_user_preferences.sql @@ -0,0 +1,2 @@ +ALTER TABLE "user" ADD COLUMN "preferences" jsonb NOT NULL DEFAULT '{}';--> statement-breakpoint +ALTER TABLE "lesson" ADD COLUMN "age_group" varchar(10) NOT NULL DEFAULT 'adult'; diff --git a/drizzle/migrations/0013_illegal_random.sql b/drizzle/migrations/0013_illegal_random.sql new file mode 100644 index 0000000..33ec430 --- /dev/null +++ b/drizzle/migrations/0013_illegal_random.sql @@ -0,0 +1,2 @@ +ALTER TABLE "lesson" ADD COLUMN "age_group" varchar(10) DEFAULT 'adult' NOT NULL;--> statement-breakpoint +ALTER TABLE "user" ADD COLUMN "preferences" jsonb DEFAULT '{}'::jsonb NOT NULL; \ No newline at end of file diff --git a/drizzle/migrations/0014_faulty_namor.sql b/drizzle/migrations/0014_faulty_namor.sql new file mode 100644 index 0000000..233e934 --- /dev/null +++ b/drizzle/migrations/0014_faulty_namor.sql @@ -0,0 +1,2 @@ +ALTER TABLE "blueprint" ADD COLUMN "prompt_version" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "blueprint" ADD COLUMN "stale_at" timestamp; \ No newline at end of file diff --git a/drizzle/migrations/0015_mighty_prowler.sql b/drizzle/migrations/0015_mighty_prowler.sql new file mode 100644 index 0000000..147da2f --- /dev/null +++ b/drizzle/migrations/0015_mighty_prowler.sql @@ -0,0 +1,12 @@ +CREATE TABLE "eval_run" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "suite" varchar(50) NOT NULL, + "false_fail_rate" real, + "accuracy" real, + "total" integer NOT NULL, + "passed" integer DEFAULT 0 NOT NULL, + "model_version" text NOT NULL, + "prompt_version" integer DEFAULT 0 NOT NULL, + "git_sha" varchar(40), + "created_at" timestamp DEFAULT now() NOT NULL +); diff --git a/drizzle/migrations/0016_theme_map.sql b/drizzle/migrations/0016_theme_map.sql new file mode 100644 index 0000000..6262dd6 --- /dev/null +++ b/drizzle/migrations/0016_theme_map.sql @@ -0,0 +1,30 @@ +-- Theme taxonomy table (fixed, seeded once) +CREATE TABLE "theme" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "slug" text NOT NULL UNIQUE, + "label_en" text NOT NULL, + "label_fr" text NOT NULL, + "emoji" text NOT NULL +); + +-- Seed fixed taxonomy +INSERT INTO "theme" ("slug", "label_en", "label_fr", "emoji") VALUES + ('sciences-naturelles', 'Natural sciences', 'Sciences naturelles', '🔬'), + ('mathematiques', 'Mathematics', 'Mathématiques', '∑'), + ('histoire', 'History', 'Histoire', '📜'), + ('geographie', 'Geography', 'Géographie', '🌍'), + ('langues-litterature', 'Languages & literature', 'Langues & littérature', '📖'), + ('arts-culture', 'Arts & culture', 'Arts & culture', '🎨'), + ('technologie-informatique', 'Technology', 'Technologie', '💻'), + ('philosophie-ethique', 'Philosophy & ethics', 'Philosophie & éthique', '💭'), + ('economie-societe', 'Economics & society', 'Économie & société', '📊'), + ('sante-medecine', 'Health & medicine', 'Santé & médecine', '🏥'), + ('sport-activite', 'Sport & activity', 'Sport & activité', '⚡'), + ('autre', 'Other', 'Autre', '✦'); + +-- Blueprint ↔ theme join +CREATE TABLE "blueprint_theme" ( + "blueprint_id" uuid NOT NULL REFERENCES "blueprint"("id") ON DELETE CASCADE, + "theme_id" uuid NOT NULL REFERENCES "theme"("id") ON DELETE CASCADE, + PRIMARY KEY ("blueprint_id", "theme_id") +); diff --git a/drizzle/migrations/0017_married_betty_brant.sql b/drizzle/migrations/0017_married_betty_brant.sql new file mode 100644 index 0000000..856a76e --- /dev/null +++ b/drizzle/migrations/0017_married_betty_brant.sql @@ -0,0 +1,17 @@ +CREATE TABLE "blueprint_theme" ( + "blueprint_id" uuid NOT NULL, + "theme_id" uuid NOT NULL, + CONSTRAINT "blueprint_theme_blueprint_id_theme_id_pk" PRIMARY KEY("blueprint_id","theme_id") +); +--> statement-breakpoint +CREATE TABLE "theme" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "slug" text NOT NULL, + "label_en" text NOT NULL, + "label_fr" text NOT NULL, + "emoji" text NOT NULL, + CONSTRAINT "theme_slug_unique" UNIQUE("slug") +); +--> statement-breakpoint +ALTER TABLE "blueprint_theme" ADD CONSTRAINT "blueprint_theme_blueprint_id_blueprint_id_fk" FOREIGN KEY ("blueprint_id") REFERENCES "public"."blueprint"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "blueprint_theme" ADD CONSTRAINT "blueprint_theme_theme_id_theme_id_fk" FOREIGN KEY ("theme_id") REFERENCES "public"."theme"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/migrations/meta/0006_snapshot.json b/drizzle/migrations/meta/0006_snapshot.json new file mode 100644 index 0000000..2560120 --- /dev/null +++ b/drizzle/migrations/meta/0006_snapshot.json @@ -0,0 +1,1480 @@ +{ + "id": "3c4df1f5-d867-4426-a0ce-47da4643ad74", + "prevId": "c3ef9123-a7c3-43c7-8207-95e5c9441891", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "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": { + "account_provider_provider_account_id_pk": { + "name": "account_provider_provider_account_id_pk", + "columns": [ + "provider", + "provider_account_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blueprint": { + "name": "blueprint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "intent_key": { + "name": "intent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_version": { + "name": "model_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_version": { + "name": "content_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "blueprint_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "blueprint_intent_key_unique": { + "name": "blueprint_intent_key_unique", + "nullsNotDistinct": false, + "columns": [ + "intent_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.checkpoint": { + "name": "checkpoint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "segment_id": { + "name": "segment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "checkpoint_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "reference_answer": { + "name": "reference_answer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rubric_json": { + "name": "rubric_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "verify_status": { + "name": "verify_status", + "type": "verify_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "content_version": { + "name": "content_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "checkpoint_segment_id_segment_id_fk": { + "name": "checkpoint_segment_id_segment_id_fk", + "tableFrom": "checkpoint", + "tableTo": "segment", + "columnsFrom": [ + "segment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.concept": { + "name": "concept", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "blueprint_id": { + "name": "blueprint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "retrieval_ctx_ref": { + "name": "retrieval_ctx_ref", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "concept_blueprint_id_blueprint_id_fk": { + "name": "concept_blueprint_id_blueprint_id_fk", + "tableFrom": "concept", + "tableTo": "blueprint", + "columnsFrom": [ + "blueprint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.content_report": { + "name": "content_report", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "grade_id": { + "name": "grade_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "content_report_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "content_report_checkpoint_id_checkpoint_id_fk": { + "name": "content_report_checkpoint_id_checkpoint_id_fk", + "tableFrom": "content_report", + "tableTo": "checkpoint", + "columnsFrom": [ + "checkpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "content_report_grade_id_grade_id_fk": { + "name": "content_report_grade_id_grade_id_fk", + "tableFrom": "content_report", + "tableTo": "grade", + "columnsFrom": [ + "grade_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.grade": { + "name": "grade", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "response_id": { + "name": "response_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "verdict": { + "name": "verdict", + "type": "grade_verdict", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "misconception_tag": { + "name": "misconception_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "confidence": { + "name": "confidence", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "grade_response_id_response_id_fk": { + "name": "grade_response_id_response_id_fk", + "tableFrom": "grade", + "tableTo": "response", + "columnsFrom": [ + "response_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job": { + "name": "job", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "job_idempotency_key_unique": { + "name": "job_idempotency_key_unique", + "nullsNotDistinct": false, + "columns": [ + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.journey_instance": { + "name": "journey_instance", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "blueprint_id": { + "name": "blueprint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "journey_instance_user_id_user_id_fk": { + "name": "journey_instance_user_id_user_id_fk", + "tableFrom": "journey_instance", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "journey_instance_blueprint_id_blueprint_id_fk": { + "name": "journey_instance_blueprint_id_blueprint_id_fk", + "tableFrom": "journey_instance", + "tableTo": "blueprint", + "columnsFrom": [ + "blueprint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lesson": { + "name": "lesson", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "blueprint_id": { + "name": "blueprint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "est_minutes": { + "name": "est_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + } + }, + "indexes": {}, + "foreignKeys": { + "lesson_blueprint_id_blueprint_id_fk": { + "name": "lesson_blueprint_id_blueprint_id_fk", + "tableFrom": "lesson", + "tableTo": "blueprint", + "columnsFrom": [ + "blueprint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mastery": { + "name": "mastery", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "concept_id": { + "name": "concept_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0.5 + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "next_review": { + "name": "next_review", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mastery_user_id_user_id_fk": { + "name": "mastery_user_id_user_id_fk", + "tableFrom": "mastery", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "mastery_concept_id_concept_id_fk": { + "name": "mastery_concept_id_concept_id_fk", + "tableFrom": "mastery", + "tableTo": "concept", + "columnsFrom": [ + "concept_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mastery_user_id_concept_id_pk": { + "name": "mastery_user_id_concept_id_pk", + "columns": [ + "user_id", + "concept_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.misconception": { + "name": "misconception", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "concept_id": { + "name": "concept_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correction": { + "name": "correction", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verify_status": { + "name": "verify_status", + "type": "verify_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + } + }, + "indexes": {}, + "foreignKeys": { + "misconception_concept_id_concept_id_fk": { + "name": "misconception_concept_id_concept_id_fk", + "tableFrom": "misconception", + "tableTo": "concept", + "columnsFrom": [ + "concept_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.novel_misconduct_queue": { + "name": "novel_misconduct_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "grade_id": { + "name": "grade_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "response_text": { + "name": "response_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "evidence_span": { + "name": "evidence_span", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processed": { + "name": "processed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "novel_misconduct_queue_grade_id_grade_id_fk": { + "name": "novel_misconduct_queue_grade_id_grade_id_fk", + "tableFrom": "novel_misconduct_queue", + "tableTo": "grade", + "columnsFrom": [ + "grade_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "novel_misconduct_queue_checkpoint_id_checkpoint_id_fk": { + "name": "novel_misconduct_queue_checkpoint_id_checkpoint_id_fk", + "tableFrom": "novel_misconduct_queue", + "tableTo": "checkpoint", + "columnsFrom": [ + "checkpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password_reset_token": { + "name": "password_reset_token", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_reset_token_user_id_user_id_fk": { + "name": "password_reset_token_user_id_user_id_fk", + "tableFrom": "password_reset_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.response": { + "name": "response", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "self_confidence": { + "name": "self_confidence", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ts": { + "name": "ts", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "response_user_id_user_id_fk": { + "name": "response_user_id_user_id_fk", + "tableFrom": "response", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "response_checkpoint_id_checkpoint_id_fk": { + "name": "response_checkpoint_id_checkpoint_id_fk", + "tableFrom": "response", + "tableTo": "checkpoint", + "columnsFrom": [ + "checkpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.segment": { + "name": "segment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "body_json": { + "name": "body_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source_chunk_ids": { + "name": "source_chunk_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "verify_status": { + "name": "verify_status", + "type": "verify_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "content_version": { + "name": "content_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "difficulty_level": { + "name": "difficulty_level", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "segment_lesson_id_lesson_id_fk": { + "name": "segment_lesson_id_lesson_id_fk", + "tableFrom": "segment", + "tableTo": "lesson", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "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": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.site_config": { + "name": "site_config", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "varchar(100)", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.source_chunk": { + "name": "source_chunk", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "doc_ref": { + "name": "doc_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'learner'" + }, + "locale": { + "name": "locale", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification_token": { + "name": "verification_token", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verification_token_identifier_token_pk": { + "name": "verification_token_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verify_report": { + "name": "verify_report", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verdict": { + "name": "verdict", + "type": "verify_verdict", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "confidence": { + "name": "confidence", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "model_version": { + "name": "model_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.blueprint_status": { + "name": "blueprint_status", + "schema": "public", + "values": [ + "draft", + "verifying", + "published", + "flagged" + ] + }, + "public.checkpoint_kind": { + "name": "checkpoint_kind", + "schema": "public", + "values": [ + "predict", + "explain", + "solve" + ] + }, + "public.content_report_reason": { + "name": "content_report_reason", + "schema": "public", + "values": [ + "grade_wrong", + "content_error", + "other" + ] + }, + "public.grade_verdict": { + "name": "grade_verdict", + "schema": "public", + "values": [ + "mastered", + "partial", + "misconception", + "uncertain" + ] + }, + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": [ + "pending", + "running", + "done", + "failed" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "learner", + "admin", + "suspended" + ] + }, + "public.verify_status": { + "name": "verify_status", + "schema": "public", + "values": [ + "pending", + "pass", + "fail", + "uncertain" + ] + }, + "public.verify_verdict": { + "name": "verify_verdict", + "schema": "public", + "values": [ + "pass", + "fail", + "uncertain" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/migrations/meta/0008_snapshot.json b/drizzle/migrations/meta/0008_snapshot.json new file mode 100644 index 0000000..4eece02 --- /dev/null +++ b/drizzle/migrations/meta/0008_snapshot.json @@ -0,0 +1,1487 @@ +{ + "id": "79fb9002-9501-4444-a4f4-40d83f57a108", + "prevId": "3c4df1f5-d867-4426-a0ce-47da4643ad74", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "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": { + "account_provider_provider_account_id_pk": { + "name": "account_provider_provider_account_id_pk", + "columns": [ + "provider", + "provider_account_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blueprint": { + "name": "blueprint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "intent_key": { + "name": "intent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_version": { + "name": "model_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_version": { + "name": "content_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "blueprint_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "blueprint_intent_key_unique": { + "name": "blueprint_intent_key_unique", + "nullsNotDistinct": false, + "columns": [ + "intent_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.checkpoint": { + "name": "checkpoint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "segment_id": { + "name": "segment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "checkpoint_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "reference_answer": { + "name": "reference_answer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rubric_json": { + "name": "rubric_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "verify_status": { + "name": "verify_status", + "type": "verify_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "content_version": { + "name": "content_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "checkpoint_segment_id_segment_id_fk": { + "name": "checkpoint_segment_id_segment_id_fk", + "tableFrom": "checkpoint", + "tableTo": "segment", + "columnsFrom": [ + "segment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.concept": { + "name": "concept", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "blueprint_id": { + "name": "blueprint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "retrieval_ctx_ref": { + "name": "retrieval_ctx_ref", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "concept_blueprint_id_blueprint_id_fk": { + "name": "concept_blueprint_id_blueprint_id_fk", + "tableFrom": "concept", + "tableTo": "blueprint", + "columnsFrom": [ + "blueprint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.content_report": { + "name": "content_report", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "grade_id": { + "name": "grade_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "content_report_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "content_report_checkpoint_id_checkpoint_id_fk": { + "name": "content_report_checkpoint_id_checkpoint_id_fk", + "tableFrom": "content_report", + "tableTo": "checkpoint", + "columnsFrom": [ + "checkpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "content_report_grade_id_grade_id_fk": { + "name": "content_report_grade_id_grade_id_fk", + "tableFrom": "content_report", + "tableTo": "grade", + "columnsFrom": [ + "grade_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.grade": { + "name": "grade", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "response_id": { + "name": "response_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "verdict": { + "name": "verdict", + "type": "grade_verdict", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "misconception_tag": { + "name": "misconception_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "confidence": { + "name": "confidence", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "grade_response_id_response_id_fk": { + "name": "grade_response_id_response_id_fk", + "tableFrom": "grade", + "tableTo": "response", + "columnsFrom": [ + "response_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job": { + "name": "job", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "job_idempotency_key_unique": { + "name": "job_idempotency_key_unique", + "nullsNotDistinct": false, + "columns": [ + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.journey_instance": { + "name": "journey_instance", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "blueprint_id": { + "name": "blueprint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "journey_instance_user_id_user_id_fk": { + "name": "journey_instance_user_id_user_id_fk", + "tableFrom": "journey_instance", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "journey_instance_blueprint_id_blueprint_id_fk": { + "name": "journey_instance_blueprint_id_blueprint_id_fk", + "tableFrom": "journey_instance", + "tableTo": "blueprint", + "columnsFrom": [ + "blueprint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lesson": { + "name": "lesson", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "blueprint_id": { + "name": "blueprint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "est_minutes": { + "name": "est_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "locale": { + "name": "locale", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'en'" + } + }, + "indexes": {}, + "foreignKeys": { + "lesson_blueprint_id_blueprint_id_fk": { + "name": "lesson_blueprint_id_blueprint_id_fk", + "tableFrom": "lesson", + "tableTo": "blueprint", + "columnsFrom": [ + "blueprint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mastery": { + "name": "mastery", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "concept_id": { + "name": "concept_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0.5 + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "next_review": { + "name": "next_review", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mastery_user_id_user_id_fk": { + "name": "mastery_user_id_user_id_fk", + "tableFrom": "mastery", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "mastery_concept_id_concept_id_fk": { + "name": "mastery_concept_id_concept_id_fk", + "tableFrom": "mastery", + "tableTo": "concept", + "columnsFrom": [ + "concept_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mastery_user_id_concept_id_pk": { + "name": "mastery_user_id_concept_id_pk", + "columns": [ + "user_id", + "concept_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.misconception": { + "name": "misconception", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "concept_id": { + "name": "concept_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correction": { + "name": "correction", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verify_status": { + "name": "verify_status", + "type": "verify_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + } + }, + "indexes": {}, + "foreignKeys": { + "misconception_concept_id_concept_id_fk": { + "name": "misconception_concept_id_concept_id_fk", + "tableFrom": "misconception", + "tableTo": "concept", + "columnsFrom": [ + "concept_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.novel_misconduct_queue": { + "name": "novel_misconduct_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "grade_id": { + "name": "grade_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "response_text": { + "name": "response_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "evidence_span": { + "name": "evidence_span", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processed": { + "name": "processed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "novel_misconduct_queue_grade_id_grade_id_fk": { + "name": "novel_misconduct_queue_grade_id_grade_id_fk", + "tableFrom": "novel_misconduct_queue", + "tableTo": "grade", + "columnsFrom": [ + "grade_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "novel_misconduct_queue_checkpoint_id_checkpoint_id_fk": { + "name": "novel_misconduct_queue_checkpoint_id_checkpoint_id_fk", + "tableFrom": "novel_misconduct_queue", + "tableTo": "checkpoint", + "columnsFrom": [ + "checkpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password_reset_token": { + "name": "password_reset_token", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_reset_token_user_id_user_id_fk": { + "name": "password_reset_token_user_id_user_id_fk", + "tableFrom": "password_reset_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.response": { + "name": "response", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "self_confidence": { + "name": "self_confidence", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ts": { + "name": "ts", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "response_user_id_user_id_fk": { + "name": "response_user_id_user_id_fk", + "tableFrom": "response", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "response_checkpoint_id_checkpoint_id_fk": { + "name": "response_checkpoint_id_checkpoint_id_fk", + "tableFrom": "response", + "tableTo": "checkpoint", + "columnsFrom": [ + "checkpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.segment": { + "name": "segment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "body_json": { + "name": "body_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source_chunk_ids": { + "name": "source_chunk_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "verify_status": { + "name": "verify_status", + "type": "verify_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "content_version": { + "name": "content_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "difficulty_level": { + "name": "difficulty_level", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "segment_lesson_id_lesson_id_fk": { + "name": "segment_lesson_id_lesson_id_fk", + "tableFrom": "segment", + "tableTo": "lesson", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "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": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.site_config": { + "name": "site_config", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "varchar(100)", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.source_chunk": { + "name": "source_chunk", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "doc_ref": { + "name": "doc_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'learner'" + }, + "locale": { + "name": "locale", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification_token": { + "name": "verification_token", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verification_token_identifier_token_pk": { + "name": "verification_token_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verify_report": { + "name": "verify_report", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verdict": { + "name": "verdict", + "type": "verify_verdict", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "confidence": { + "name": "confidence", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "model_version": { + "name": "model_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.blueprint_status": { + "name": "blueprint_status", + "schema": "public", + "values": [ + "draft", + "verifying", + "published", + "flagged" + ] + }, + "public.checkpoint_kind": { + "name": "checkpoint_kind", + "schema": "public", + "values": [ + "predict", + "explain", + "solve" + ] + }, + "public.content_report_reason": { + "name": "content_report_reason", + "schema": "public", + "values": [ + "grade_wrong", + "content_error", + "other" + ] + }, + "public.grade_verdict": { + "name": "grade_verdict", + "schema": "public", + "values": [ + "mastered", + "partial", + "misconception", + "uncertain" + ] + }, + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": [ + "pending", + "running", + "done", + "failed" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "learner", + "admin", + "suspended" + ] + }, + "public.verify_status": { + "name": "verify_status", + "schema": "public", + "values": [ + "pending", + "pass", + "fail", + "uncertain" + ] + }, + "public.verify_verdict": { + "name": "verify_verdict", + "schema": "public", + "values": [ + "pass", + "fail", + "uncertain" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/migrations/meta/0009_snapshot.json b/drizzle/migrations/meta/0009_snapshot.json new file mode 100644 index 0000000..69b790f --- /dev/null +++ b/drizzle/migrations/meta/0009_snapshot.json @@ -0,0 +1,1574 @@ +{ + "id": "4e1fa8a7-5e38-41bc-b603-0ad2310e518f", + "prevId": "79fb9002-9501-4444-a4f4-40d83f57a108", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "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": { + "account_provider_provider_account_id_pk": { + "name": "account_provider_provider_account_id_pk", + "columns": [ + "provider", + "provider_account_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blueprint": { + "name": "blueprint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "intent_key": { + "name": "intent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_version": { + "name": "model_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_version": { + "name": "content_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "blueprint_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "blueprint_intent_key_unique": { + "name": "blueprint_intent_key_unique", + "nullsNotDistinct": false, + "columns": [ + "intent_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.checkpoint": { + "name": "checkpoint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "segment_id": { + "name": "segment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "checkpoint_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "reference_answer": { + "name": "reference_answer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rubric_json": { + "name": "rubric_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "verify_status": { + "name": "verify_status", + "type": "verify_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "content_version": { + "name": "content_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "checkpoint_segment_id_segment_id_fk": { + "name": "checkpoint_segment_id_segment_id_fk", + "tableFrom": "checkpoint", + "tableTo": "segment", + "columnsFrom": [ + "segment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.concept": { + "name": "concept", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "blueprint_id": { + "name": "blueprint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "retrieval_ctx_ref": { + "name": "retrieval_ctx_ref", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "concept_blueprint_id_blueprint_id_fk": { + "name": "concept_blueprint_id_blueprint_id_fk", + "tableFrom": "concept", + "tableTo": "blueprint", + "columnsFrom": [ + "blueprint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.content_report": { + "name": "content_report", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "grade_id": { + "name": "grade_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "content_report_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "content_report_checkpoint_id_checkpoint_id_fk": { + "name": "content_report_checkpoint_id_checkpoint_id_fk", + "tableFrom": "content_report", + "tableTo": "checkpoint", + "columnsFrom": [ + "checkpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "content_report_grade_id_grade_id_fk": { + "name": "content_report_grade_id_grade_id_fk", + "tableFrom": "content_report", + "tableTo": "grade", + "columnsFrom": [ + "grade_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.grade": { + "name": "grade", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "response_id": { + "name": "response_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "verdict": { + "name": "verdict", + "type": "grade_verdict", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "misconception_tag": { + "name": "misconception_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "confidence": { + "name": "confidence", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "grade_response_id_response_id_fk": { + "name": "grade_response_id_response_id_fk", + "tableFrom": "grade", + "tableTo": "response", + "columnsFrom": [ + "response_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invite": { + "name": "invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'learner'" + }, + "invited_by": { + "name": "invited_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invite_invited_by_user_id_fk": { + "name": "invite_invited_by_user_id_fk", + "tableFrom": "invite", + "tableTo": "user", + "columnsFrom": [ + "invited_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.job": { + "name": "job", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "job_idempotency_key_unique": { + "name": "job_idempotency_key_unique", + "nullsNotDistinct": false, + "columns": [ + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.journey_instance": { + "name": "journey_instance", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "blueprint_id": { + "name": "blueprint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "journey_instance_user_id_user_id_fk": { + "name": "journey_instance_user_id_user_id_fk", + "tableFrom": "journey_instance", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "journey_instance_blueprint_id_blueprint_id_fk": { + "name": "journey_instance_blueprint_id_blueprint_id_fk", + "tableFrom": "journey_instance", + "tableTo": "blueprint", + "columnsFrom": [ + "blueprint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lesson": { + "name": "lesson", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "blueprint_id": { + "name": "blueprint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "est_minutes": { + "name": "est_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "locale": { + "name": "locale", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'en'" + } + }, + "indexes": {}, + "foreignKeys": { + "lesson_blueprint_id_blueprint_id_fk": { + "name": "lesson_blueprint_id_blueprint_id_fk", + "tableFrom": "lesson", + "tableTo": "blueprint", + "columnsFrom": [ + "blueprint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mastery": { + "name": "mastery", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "concept_id": { + "name": "concept_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0.5 + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "next_review": { + "name": "next_review", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mastery_user_id_user_id_fk": { + "name": "mastery_user_id_user_id_fk", + "tableFrom": "mastery", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "mastery_concept_id_concept_id_fk": { + "name": "mastery_concept_id_concept_id_fk", + "tableFrom": "mastery", + "tableTo": "concept", + "columnsFrom": [ + "concept_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mastery_user_id_concept_id_pk": { + "name": "mastery_user_id_concept_id_pk", + "columns": [ + "user_id", + "concept_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.misconception": { + "name": "misconception", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "concept_id": { + "name": "concept_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correction": { + "name": "correction", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verify_status": { + "name": "verify_status", + "type": "verify_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + } + }, + "indexes": {}, + "foreignKeys": { + "misconception_concept_id_concept_id_fk": { + "name": "misconception_concept_id_concept_id_fk", + "tableFrom": "misconception", + "tableTo": "concept", + "columnsFrom": [ + "concept_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.novel_misconduct_queue": { + "name": "novel_misconduct_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "grade_id": { + "name": "grade_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "response_text": { + "name": "response_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "evidence_span": { + "name": "evidence_span", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processed": { + "name": "processed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "novel_misconduct_queue_grade_id_grade_id_fk": { + "name": "novel_misconduct_queue_grade_id_grade_id_fk", + "tableFrom": "novel_misconduct_queue", + "tableTo": "grade", + "columnsFrom": [ + "grade_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "novel_misconduct_queue_checkpoint_id_checkpoint_id_fk": { + "name": "novel_misconduct_queue_checkpoint_id_checkpoint_id_fk", + "tableFrom": "novel_misconduct_queue", + "tableTo": "checkpoint", + "columnsFrom": [ + "checkpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password_reset_token": { + "name": "password_reset_token", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_reset_token_user_id_user_id_fk": { + "name": "password_reset_token_user_id_user_id_fk", + "tableFrom": "password_reset_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.response": { + "name": "response", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "self_confidence": { + "name": "self_confidence", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ts": { + "name": "ts", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "response_user_id_user_id_fk": { + "name": "response_user_id_user_id_fk", + "tableFrom": "response", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "response_checkpoint_id_checkpoint_id_fk": { + "name": "response_checkpoint_id_checkpoint_id_fk", + "tableFrom": "response", + "tableTo": "checkpoint", + "columnsFrom": [ + "checkpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.segment": { + "name": "segment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "body_json": { + "name": "body_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source_chunk_ids": { + "name": "source_chunk_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "verify_status": { + "name": "verify_status", + "type": "verify_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "content_version": { + "name": "content_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "difficulty_level": { + "name": "difficulty_level", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "segment_lesson_id_lesson_id_fk": { + "name": "segment_lesson_id_lesson_id_fk", + "tableFrom": "segment", + "tableTo": "lesson", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "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": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.site_config": { + "name": "site_config", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "varchar(100)", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.source_chunk": { + "name": "source_chunk", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "doc_ref": { + "name": "doc_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'learner'" + }, + "locale": { + "name": "locale", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification_token": { + "name": "verification_token", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verification_token_identifier_token_pk": { + "name": "verification_token_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verify_report": { + "name": "verify_report", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verdict": { + "name": "verdict", + "type": "verify_verdict", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "confidence": { + "name": "confidence", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "model_version": { + "name": "model_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.blueprint_status": { + "name": "blueprint_status", + "schema": "public", + "values": [ + "draft", + "verifying", + "published", + "flagged" + ] + }, + "public.checkpoint_kind": { + "name": "checkpoint_kind", + "schema": "public", + "values": [ + "predict", + "explain", + "solve" + ] + }, + "public.content_report_reason": { + "name": "content_report_reason", + "schema": "public", + "values": [ + "grade_wrong", + "content_error", + "other" + ] + }, + "public.grade_verdict": { + "name": "grade_verdict", + "schema": "public", + "values": [ + "mastered", + "partial", + "misconception", + "uncertain" + ] + }, + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": [ + "pending", + "running", + "done", + "failed" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "learner", + "admin", + "suspended" + ] + }, + "public.verify_status": { + "name": "verify_status", + "schema": "public", + "values": [ + "pending", + "pass", + "fail", + "uncertain" + ] + }, + "public.verify_verdict": { + "name": "verify_verdict", + "schema": "public", + "values": [ + "pass", + "fail", + "uncertain" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/migrations/meta/0011_snapshot.json b/drizzle/migrations/meta/0011_snapshot.json new file mode 100644 index 0000000..c33d418 --- /dev/null +++ b/drizzle/migrations/meta/0011_snapshot.json @@ -0,0 +1,1581 @@ +{ + "id": "acd8ee1e-67ef-49c5-bf74-30e575f9b99a", + "prevId": "4e1fa8a7-5e38-41bc-b603-0ad2310e518f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "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": { + "account_provider_provider_account_id_pk": { + "name": "account_provider_provider_account_id_pk", + "columns": [ + "provider", + "provider_account_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blueprint": { + "name": "blueprint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "intent_key": { + "name": "intent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_version": { + "name": "model_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_version": { + "name": "content_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "blueprint_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "blueprint_intent_key_unique": { + "name": "blueprint_intent_key_unique", + "nullsNotDistinct": false, + "columns": [ + "intent_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.checkpoint": { + "name": "checkpoint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "segment_id": { + "name": "segment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "checkpoint_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "reference_answer": { + "name": "reference_answer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rubric_json": { + "name": "rubric_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "verify_status": { + "name": "verify_status", + "type": "verify_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "content_version": { + "name": "content_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "checkpoint_segment_id_segment_id_fk": { + "name": "checkpoint_segment_id_segment_id_fk", + "tableFrom": "checkpoint", + "tableTo": "segment", + "columnsFrom": [ + "segment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.concept": { + "name": "concept", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "blueprint_id": { + "name": "blueprint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "retrieval_ctx_ref": { + "name": "retrieval_ctx_ref", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "concept_blueprint_id_blueprint_id_fk": { + "name": "concept_blueprint_id_blueprint_id_fk", + "tableFrom": "concept", + "tableTo": "blueprint", + "columnsFrom": [ + "blueprint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.content_report": { + "name": "content_report", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "grade_id": { + "name": "grade_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "content_report_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "content_report_checkpoint_id_checkpoint_id_fk": { + "name": "content_report_checkpoint_id_checkpoint_id_fk", + "tableFrom": "content_report", + "tableTo": "checkpoint", + "columnsFrom": [ + "checkpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "content_report_grade_id_grade_id_fk": { + "name": "content_report_grade_id_grade_id_fk", + "tableFrom": "content_report", + "tableTo": "grade", + "columnsFrom": [ + "grade_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.grade": { + "name": "grade", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "response_id": { + "name": "response_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "verdict": { + "name": "verdict", + "type": "grade_verdict", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "misconception_tag": { + "name": "misconception_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "confidence": { + "name": "confidence", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "grade_response_id_response_id_fk": { + "name": "grade_response_id_response_id_fk", + "tableFrom": "grade", + "tableTo": "response", + "columnsFrom": [ + "response_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invite": { + "name": "invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'learner'" + }, + "invited_by": { + "name": "invited_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invite_invited_by_user_id_fk": { + "name": "invite_invited_by_user_id_fk", + "tableFrom": "invite", + "tableTo": "user", + "columnsFrom": [ + "invited_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.job": { + "name": "job", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "job_idempotency_key_unique": { + "name": "job_idempotency_key_unique", + "nullsNotDistinct": false, + "columns": [ + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.journey_instance": { + "name": "journey_instance", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "blueprint_id": { + "name": "blueprint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "journey_instance_user_id_user_id_fk": { + "name": "journey_instance_user_id_user_id_fk", + "tableFrom": "journey_instance", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "journey_instance_blueprint_id_blueprint_id_fk": { + "name": "journey_instance_blueprint_id_blueprint_id_fk", + "tableFrom": "journey_instance", + "tableTo": "blueprint", + "columnsFrom": [ + "blueprint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lesson": { + "name": "lesson", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "blueprint_id": { + "name": "blueprint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "est_minutes": { + "name": "est_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "locale": { + "name": "locale", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "depth": { + "name": "depth", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'standard'" + } + }, + "indexes": {}, + "foreignKeys": { + "lesson_blueprint_id_blueprint_id_fk": { + "name": "lesson_blueprint_id_blueprint_id_fk", + "tableFrom": "lesson", + "tableTo": "blueprint", + "columnsFrom": [ + "blueprint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mastery": { + "name": "mastery", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "concept_id": { + "name": "concept_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0.5 + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "next_review": { + "name": "next_review", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mastery_user_id_user_id_fk": { + "name": "mastery_user_id_user_id_fk", + "tableFrom": "mastery", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "mastery_concept_id_concept_id_fk": { + "name": "mastery_concept_id_concept_id_fk", + "tableFrom": "mastery", + "tableTo": "concept", + "columnsFrom": [ + "concept_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mastery_user_id_concept_id_pk": { + "name": "mastery_user_id_concept_id_pk", + "columns": [ + "user_id", + "concept_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.misconception": { + "name": "misconception", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "concept_id": { + "name": "concept_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correction": { + "name": "correction", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verify_status": { + "name": "verify_status", + "type": "verify_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + } + }, + "indexes": {}, + "foreignKeys": { + "misconception_concept_id_concept_id_fk": { + "name": "misconception_concept_id_concept_id_fk", + "tableFrom": "misconception", + "tableTo": "concept", + "columnsFrom": [ + "concept_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.novel_misconduct_queue": { + "name": "novel_misconduct_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "grade_id": { + "name": "grade_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "response_text": { + "name": "response_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "evidence_span": { + "name": "evidence_span", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processed": { + "name": "processed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "novel_misconduct_queue_grade_id_grade_id_fk": { + "name": "novel_misconduct_queue_grade_id_grade_id_fk", + "tableFrom": "novel_misconduct_queue", + "tableTo": "grade", + "columnsFrom": [ + "grade_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "novel_misconduct_queue_checkpoint_id_checkpoint_id_fk": { + "name": "novel_misconduct_queue_checkpoint_id_checkpoint_id_fk", + "tableFrom": "novel_misconduct_queue", + "tableTo": "checkpoint", + "columnsFrom": [ + "checkpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password_reset_token": { + "name": "password_reset_token", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_reset_token_user_id_user_id_fk": { + "name": "password_reset_token_user_id_user_id_fk", + "tableFrom": "password_reset_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.response": { + "name": "response", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "self_confidence": { + "name": "self_confidence", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ts": { + "name": "ts", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "response_user_id_user_id_fk": { + "name": "response_user_id_user_id_fk", + "tableFrom": "response", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "response_checkpoint_id_checkpoint_id_fk": { + "name": "response_checkpoint_id_checkpoint_id_fk", + "tableFrom": "response", + "tableTo": "checkpoint", + "columnsFrom": [ + "checkpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.segment": { + "name": "segment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "body_json": { + "name": "body_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source_chunk_ids": { + "name": "source_chunk_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "verify_status": { + "name": "verify_status", + "type": "verify_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "content_version": { + "name": "content_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "difficulty_level": { + "name": "difficulty_level", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "segment_lesson_id_lesson_id_fk": { + "name": "segment_lesson_id_lesson_id_fk", + "tableFrom": "segment", + "tableTo": "lesson", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "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": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.site_config": { + "name": "site_config", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "varchar(100)", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.source_chunk": { + "name": "source_chunk", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "doc_ref": { + "name": "doc_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'learner'" + }, + "locale": { + "name": "locale", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification_token": { + "name": "verification_token", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verification_token_identifier_token_pk": { + "name": "verification_token_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verify_report": { + "name": "verify_report", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verdict": { + "name": "verdict", + "type": "verify_verdict", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "confidence": { + "name": "confidence", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "model_version": { + "name": "model_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.blueprint_status": { + "name": "blueprint_status", + "schema": "public", + "values": [ + "draft", + "verifying", + "published", + "flagged" + ] + }, + "public.checkpoint_kind": { + "name": "checkpoint_kind", + "schema": "public", + "values": [ + "predict", + "explain", + "solve" + ] + }, + "public.content_report_reason": { + "name": "content_report_reason", + "schema": "public", + "values": [ + "grade_wrong", + "content_error", + "other" + ] + }, + "public.grade_verdict": { + "name": "grade_verdict", + "schema": "public", + "values": [ + "mastered", + "partial", + "misconception", + "uncertain" + ] + }, + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": [ + "pending", + "running", + "done", + "failed" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "learner", + "admin", + "suspended" + ] + }, + "public.verify_status": { + "name": "verify_status", + "schema": "public", + "values": [ + "pending", + "pass", + "fail", + "uncertain" + ] + }, + "public.verify_verdict": { + "name": "verify_verdict", + "schema": "public", + "values": [ + "pass", + "fail", + "uncertain" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/migrations/meta/0013_snapshot.json b/drizzle/migrations/meta/0013_snapshot.json new file mode 100644 index 0000000..30a3b62 --- /dev/null +++ b/drizzle/migrations/meta/0013_snapshot.json @@ -0,0 +1,1595 @@ +{ + "id": "0c94d780-f40b-4be6-861d-2d7a766ab835", + "prevId": "acd8ee1e-67ef-49c5-bf74-30e575f9b99a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "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": { + "account_provider_provider_account_id_pk": { + "name": "account_provider_provider_account_id_pk", + "columns": [ + "provider", + "provider_account_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blueprint": { + "name": "blueprint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "intent_key": { + "name": "intent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_version": { + "name": "model_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_version": { + "name": "content_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "blueprint_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "blueprint_intent_key_unique": { + "name": "blueprint_intent_key_unique", + "nullsNotDistinct": false, + "columns": [ + "intent_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.checkpoint": { + "name": "checkpoint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "segment_id": { + "name": "segment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "checkpoint_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "reference_answer": { + "name": "reference_answer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rubric_json": { + "name": "rubric_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "verify_status": { + "name": "verify_status", + "type": "verify_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "content_version": { + "name": "content_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "checkpoint_segment_id_segment_id_fk": { + "name": "checkpoint_segment_id_segment_id_fk", + "tableFrom": "checkpoint", + "tableTo": "segment", + "columnsFrom": [ + "segment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.concept": { + "name": "concept", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "blueprint_id": { + "name": "blueprint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "retrieval_ctx_ref": { + "name": "retrieval_ctx_ref", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "concept_blueprint_id_blueprint_id_fk": { + "name": "concept_blueprint_id_blueprint_id_fk", + "tableFrom": "concept", + "tableTo": "blueprint", + "columnsFrom": [ + "blueprint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.content_report": { + "name": "content_report", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "grade_id": { + "name": "grade_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "content_report_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "content_report_checkpoint_id_checkpoint_id_fk": { + "name": "content_report_checkpoint_id_checkpoint_id_fk", + "tableFrom": "content_report", + "tableTo": "checkpoint", + "columnsFrom": [ + "checkpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "content_report_grade_id_grade_id_fk": { + "name": "content_report_grade_id_grade_id_fk", + "tableFrom": "content_report", + "tableTo": "grade", + "columnsFrom": [ + "grade_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.grade": { + "name": "grade", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "response_id": { + "name": "response_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "verdict": { + "name": "verdict", + "type": "grade_verdict", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "misconception_tag": { + "name": "misconception_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "confidence": { + "name": "confidence", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "grade_response_id_response_id_fk": { + "name": "grade_response_id_response_id_fk", + "tableFrom": "grade", + "tableTo": "response", + "columnsFrom": [ + "response_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invite": { + "name": "invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'learner'" + }, + "invited_by": { + "name": "invited_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invite_invited_by_user_id_fk": { + "name": "invite_invited_by_user_id_fk", + "tableFrom": "invite", + "tableTo": "user", + "columnsFrom": [ + "invited_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.job": { + "name": "job", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "job_idempotency_key_unique": { + "name": "job_idempotency_key_unique", + "nullsNotDistinct": false, + "columns": [ + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.journey_instance": { + "name": "journey_instance", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "blueprint_id": { + "name": "blueprint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "journey_instance_user_id_user_id_fk": { + "name": "journey_instance_user_id_user_id_fk", + "tableFrom": "journey_instance", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "journey_instance_blueprint_id_blueprint_id_fk": { + "name": "journey_instance_blueprint_id_blueprint_id_fk", + "tableFrom": "journey_instance", + "tableTo": "blueprint", + "columnsFrom": [ + "blueprint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lesson": { + "name": "lesson", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "blueprint_id": { + "name": "blueprint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "est_minutes": { + "name": "est_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "locale": { + "name": "locale", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "depth": { + "name": "depth", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'standard'" + }, + "age_group": { + "name": "age_group", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'adult'" + } + }, + "indexes": {}, + "foreignKeys": { + "lesson_blueprint_id_blueprint_id_fk": { + "name": "lesson_blueprint_id_blueprint_id_fk", + "tableFrom": "lesson", + "tableTo": "blueprint", + "columnsFrom": [ + "blueprint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mastery": { + "name": "mastery", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "concept_id": { + "name": "concept_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0.5 + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "next_review": { + "name": "next_review", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mastery_user_id_user_id_fk": { + "name": "mastery_user_id_user_id_fk", + "tableFrom": "mastery", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "mastery_concept_id_concept_id_fk": { + "name": "mastery_concept_id_concept_id_fk", + "tableFrom": "mastery", + "tableTo": "concept", + "columnsFrom": [ + "concept_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mastery_user_id_concept_id_pk": { + "name": "mastery_user_id_concept_id_pk", + "columns": [ + "user_id", + "concept_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.misconception": { + "name": "misconception", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "concept_id": { + "name": "concept_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correction": { + "name": "correction", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verify_status": { + "name": "verify_status", + "type": "verify_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + } + }, + "indexes": {}, + "foreignKeys": { + "misconception_concept_id_concept_id_fk": { + "name": "misconception_concept_id_concept_id_fk", + "tableFrom": "misconception", + "tableTo": "concept", + "columnsFrom": [ + "concept_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.novel_misconduct_queue": { + "name": "novel_misconduct_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "grade_id": { + "name": "grade_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "response_text": { + "name": "response_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "evidence_span": { + "name": "evidence_span", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processed": { + "name": "processed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "novel_misconduct_queue_grade_id_grade_id_fk": { + "name": "novel_misconduct_queue_grade_id_grade_id_fk", + "tableFrom": "novel_misconduct_queue", + "tableTo": "grade", + "columnsFrom": [ + "grade_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "novel_misconduct_queue_checkpoint_id_checkpoint_id_fk": { + "name": "novel_misconduct_queue_checkpoint_id_checkpoint_id_fk", + "tableFrom": "novel_misconduct_queue", + "tableTo": "checkpoint", + "columnsFrom": [ + "checkpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password_reset_token": { + "name": "password_reset_token", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_reset_token_user_id_user_id_fk": { + "name": "password_reset_token_user_id_user_id_fk", + "tableFrom": "password_reset_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.response": { + "name": "response", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "self_confidence": { + "name": "self_confidence", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ts": { + "name": "ts", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "response_user_id_user_id_fk": { + "name": "response_user_id_user_id_fk", + "tableFrom": "response", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "response_checkpoint_id_checkpoint_id_fk": { + "name": "response_checkpoint_id_checkpoint_id_fk", + "tableFrom": "response", + "tableTo": "checkpoint", + "columnsFrom": [ + "checkpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.segment": { + "name": "segment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "body_json": { + "name": "body_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source_chunk_ids": { + "name": "source_chunk_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "verify_status": { + "name": "verify_status", + "type": "verify_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "content_version": { + "name": "content_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "difficulty_level": { + "name": "difficulty_level", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "segment_lesson_id_lesson_id_fk": { + "name": "segment_lesson_id_lesson_id_fk", + "tableFrom": "segment", + "tableTo": "lesson", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "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": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.site_config": { + "name": "site_config", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "varchar(100)", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.source_chunk": { + "name": "source_chunk", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "doc_ref": { + "name": "doc_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'learner'" + }, + "locale": { + "name": "locale", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preferences": { + "name": "preferences", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification_token": { + "name": "verification_token", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verification_token_identifier_token_pk": { + "name": "verification_token_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verify_report": { + "name": "verify_report", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verdict": { + "name": "verdict", + "type": "verify_verdict", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "confidence": { + "name": "confidence", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "model_version": { + "name": "model_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.blueprint_status": { + "name": "blueprint_status", + "schema": "public", + "values": [ + "draft", + "verifying", + "published", + "flagged" + ] + }, + "public.checkpoint_kind": { + "name": "checkpoint_kind", + "schema": "public", + "values": [ + "predict", + "explain", + "solve" + ] + }, + "public.content_report_reason": { + "name": "content_report_reason", + "schema": "public", + "values": [ + "grade_wrong", + "content_error", + "other" + ] + }, + "public.grade_verdict": { + "name": "grade_verdict", + "schema": "public", + "values": [ + "mastered", + "partial", + "misconception", + "uncertain" + ] + }, + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": [ + "pending", + "running", + "done", + "failed" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "learner", + "admin", + "suspended" + ] + }, + "public.verify_status": { + "name": "verify_status", + "schema": "public", + "values": [ + "pending", + "pass", + "fail", + "uncertain" + ] + }, + "public.verify_verdict": { + "name": "verify_verdict", + "schema": "public", + "values": [ + "pass", + "fail", + "uncertain" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/migrations/meta/0014_snapshot.json b/drizzle/migrations/meta/0014_snapshot.json new file mode 100644 index 0000000..e9f2cfe --- /dev/null +++ b/drizzle/migrations/meta/0014_snapshot.json @@ -0,0 +1,1608 @@ +{ + "id": "47e387a6-93f3-45ac-be60-401fe77c6f54", + "prevId": "0c94d780-f40b-4be6-861d-2d7a766ab835", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "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": { + "account_provider_provider_account_id_pk": { + "name": "account_provider_provider_account_id_pk", + "columns": [ + "provider", + "provider_account_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blueprint": { + "name": "blueprint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "intent_key": { + "name": "intent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_version": { + "name": "model_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_version": { + "name": "content_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "prompt_version": { + "name": "prompt_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stale_at": { + "name": "stale_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "blueprint_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "blueprint_intent_key_unique": { + "name": "blueprint_intent_key_unique", + "nullsNotDistinct": false, + "columns": [ + "intent_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.checkpoint": { + "name": "checkpoint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "segment_id": { + "name": "segment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "checkpoint_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "reference_answer": { + "name": "reference_answer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rubric_json": { + "name": "rubric_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "verify_status": { + "name": "verify_status", + "type": "verify_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "content_version": { + "name": "content_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "checkpoint_segment_id_segment_id_fk": { + "name": "checkpoint_segment_id_segment_id_fk", + "tableFrom": "checkpoint", + "tableTo": "segment", + "columnsFrom": [ + "segment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.concept": { + "name": "concept", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "blueprint_id": { + "name": "blueprint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "retrieval_ctx_ref": { + "name": "retrieval_ctx_ref", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "concept_blueprint_id_blueprint_id_fk": { + "name": "concept_blueprint_id_blueprint_id_fk", + "tableFrom": "concept", + "tableTo": "blueprint", + "columnsFrom": [ + "blueprint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.content_report": { + "name": "content_report", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "grade_id": { + "name": "grade_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "content_report_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "content_report_checkpoint_id_checkpoint_id_fk": { + "name": "content_report_checkpoint_id_checkpoint_id_fk", + "tableFrom": "content_report", + "tableTo": "checkpoint", + "columnsFrom": [ + "checkpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "content_report_grade_id_grade_id_fk": { + "name": "content_report_grade_id_grade_id_fk", + "tableFrom": "content_report", + "tableTo": "grade", + "columnsFrom": [ + "grade_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.grade": { + "name": "grade", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "response_id": { + "name": "response_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "verdict": { + "name": "verdict", + "type": "grade_verdict", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "misconception_tag": { + "name": "misconception_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "confidence": { + "name": "confidence", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "grade_response_id_response_id_fk": { + "name": "grade_response_id_response_id_fk", + "tableFrom": "grade", + "tableTo": "response", + "columnsFrom": [ + "response_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invite": { + "name": "invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'learner'" + }, + "invited_by": { + "name": "invited_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invite_invited_by_user_id_fk": { + "name": "invite_invited_by_user_id_fk", + "tableFrom": "invite", + "tableTo": "user", + "columnsFrom": [ + "invited_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.job": { + "name": "job", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "job_idempotency_key_unique": { + "name": "job_idempotency_key_unique", + "nullsNotDistinct": false, + "columns": [ + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.journey_instance": { + "name": "journey_instance", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "blueprint_id": { + "name": "blueprint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "journey_instance_user_id_user_id_fk": { + "name": "journey_instance_user_id_user_id_fk", + "tableFrom": "journey_instance", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "journey_instance_blueprint_id_blueprint_id_fk": { + "name": "journey_instance_blueprint_id_blueprint_id_fk", + "tableFrom": "journey_instance", + "tableTo": "blueprint", + "columnsFrom": [ + "blueprint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lesson": { + "name": "lesson", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "blueprint_id": { + "name": "blueprint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "est_minutes": { + "name": "est_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "locale": { + "name": "locale", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "depth": { + "name": "depth", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'standard'" + }, + "age_group": { + "name": "age_group", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'adult'" + } + }, + "indexes": {}, + "foreignKeys": { + "lesson_blueprint_id_blueprint_id_fk": { + "name": "lesson_blueprint_id_blueprint_id_fk", + "tableFrom": "lesson", + "tableTo": "blueprint", + "columnsFrom": [ + "blueprint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mastery": { + "name": "mastery", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "concept_id": { + "name": "concept_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0.5 + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "next_review": { + "name": "next_review", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mastery_user_id_user_id_fk": { + "name": "mastery_user_id_user_id_fk", + "tableFrom": "mastery", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "mastery_concept_id_concept_id_fk": { + "name": "mastery_concept_id_concept_id_fk", + "tableFrom": "mastery", + "tableTo": "concept", + "columnsFrom": [ + "concept_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mastery_user_id_concept_id_pk": { + "name": "mastery_user_id_concept_id_pk", + "columns": [ + "user_id", + "concept_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.misconception": { + "name": "misconception", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "concept_id": { + "name": "concept_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correction": { + "name": "correction", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verify_status": { + "name": "verify_status", + "type": "verify_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + } + }, + "indexes": {}, + "foreignKeys": { + "misconception_concept_id_concept_id_fk": { + "name": "misconception_concept_id_concept_id_fk", + "tableFrom": "misconception", + "tableTo": "concept", + "columnsFrom": [ + "concept_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.novel_misconduct_queue": { + "name": "novel_misconduct_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "grade_id": { + "name": "grade_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "response_text": { + "name": "response_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "evidence_span": { + "name": "evidence_span", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processed": { + "name": "processed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "novel_misconduct_queue_grade_id_grade_id_fk": { + "name": "novel_misconduct_queue_grade_id_grade_id_fk", + "tableFrom": "novel_misconduct_queue", + "tableTo": "grade", + "columnsFrom": [ + "grade_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "novel_misconduct_queue_checkpoint_id_checkpoint_id_fk": { + "name": "novel_misconduct_queue_checkpoint_id_checkpoint_id_fk", + "tableFrom": "novel_misconduct_queue", + "tableTo": "checkpoint", + "columnsFrom": [ + "checkpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password_reset_token": { + "name": "password_reset_token", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_reset_token_user_id_user_id_fk": { + "name": "password_reset_token_user_id_user_id_fk", + "tableFrom": "password_reset_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.response": { + "name": "response", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "self_confidence": { + "name": "self_confidence", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ts": { + "name": "ts", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "response_user_id_user_id_fk": { + "name": "response_user_id_user_id_fk", + "tableFrom": "response", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "response_checkpoint_id_checkpoint_id_fk": { + "name": "response_checkpoint_id_checkpoint_id_fk", + "tableFrom": "response", + "tableTo": "checkpoint", + "columnsFrom": [ + "checkpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.segment": { + "name": "segment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "body_json": { + "name": "body_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source_chunk_ids": { + "name": "source_chunk_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "verify_status": { + "name": "verify_status", + "type": "verify_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "content_version": { + "name": "content_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "difficulty_level": { + "name": "difficulty_level", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "segment_lesson_id_lesson_id_fk": { + "name": "segment_lesson_id_lesson_id_fk", + "tableFrom": "segment", + "tableTo": "lesson", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "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": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.site_config": { + "name": "site_config", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "varchar(100)", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.source_chunk": { + "name": "source_chunk", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "doc_ref": { + "name": "doc_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'learner'" + }, + "locale": { + "name": "locale", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preferences": { + "name": "preferences", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification_token": { + "name": "verification_token", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verification_token_identifier_token_pk": { + "name": "verification_token_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verify_report": { + "name": "verify_report", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verdict": { + "name": "verdict", + "type": "verify_verdict", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "confidence": { + "name": "confidence", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "model_version": { + "name": "model_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.blueprint_status": { + "name": "blueprint_status", + "schema": "public", + "values": [ + "draft", + "verifying", + "published", + "flagged" + ] + }, + "public.checkpoint_kind": { + "name": "checkpoint_kind", + "schema": "public", + "values": [ + "predict", + "explain", + "solve" + ] + }, + "public.content_report_reason": { + "name": "content_report_reason", + "schema": "public", + "values": [ + "grade_wrong", + "content_error", + "other" + ] + }, + "public.grade_verdict": { + "name": "grade_verdict", + "schema": "public", + "values": [ + "mastered", + "partial", + "misconception", + "uncertain" + ] + }, + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": [ + "pending", + "running", + "done", + "failed" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "learner", + "admin", + "suspended" + ] + }, + "public.verify_status": { + "name": "verify_status", + "schema": "public", + "values": [ + "pending", + "pass", + "fail", + "uncertain" + ] + }, + "public.verify_verdict": { + "name": "verify_verdict", + "schema": "public", + "values": [ + "pass", + "fail", + "uncertain" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/migrations/meta/0015_snapshot.json b/drizzle/migrations/meta/0015_snapshot.json new file mode 100644 index 0000000..d24bfd9 --- /dev/null +++ b/drizzle/migrations/meta/0015_snapshot.json @@ -0,0 +1,1685 @@ +{ + "id": "e057ff8f-3be2-4981-b3f5-03f19af58df6", + "prevId": "47e387a6-93f3-45ac-be60-401fe77c6f54", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "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": { + "account_provider_provider_account_id_pk": { + "name": "account_provider_provider_account_id_pk", + "columns": [ + "provider", + "provider_account_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blueprint": { + "name": "blueprint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "intent_key": { + "name": "intent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_version": { + "name": "model_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_version": { + "name": "content_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "prompt_version": { + "name": "prompt_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stale_at": { + "name": "stale_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "blueprint_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "blueprint_intent_key_unique": { + "name": "blueprint_intent_key_unique", + "nullsNotDistinct": false, + "columns": [ + "intent_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.checkpoint": { + "name": "checkpoint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "segment_id": { + "name": "segment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "checkpoint_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "reference_answer": { + "name": "reference_answer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rubric_json": { + "name": "rubric_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "verify_status": { + "name": "verify_status", + "type": "verify_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "content_version": { + "name": "content_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "checkpoint_segment_id_segment_id_fk": { + "name": "checkpoint_segment_id_segment_id_fk", + "tableFrom": "checkpoint", + "tableTo": "segment", + "columnsFrom": [ + "segment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.concept": { + "name": "concept", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "blueprint_id": { + "name": "blueprint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "retrieval_ctx_ref": { + "name": "retrieval_ctx_ref", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "concept_blueprint_id_blueprint_id_fk": { + "name": "concept_blueprint_id_blueprint_id_fk", + "tableFrom": "concept", + "tableTo": "blueprint", + "columnsFrom": [ + "blueprint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.content_report": { + "name": "content_report", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "grade_id": { + "name": "grade_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "content_report_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "content_report_checkpoint_id_checkpoint_id_fk": { + "name": "content_report_checkpoint_id_checkpoint_id_fk", + "tableFrom": "content_report", + "tableTo": "checkpoint", + "columnsFrom": [ + "checkpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "content_report_grade_id_grade_id_fk": { + "name": "content_report_grade_id_grade_id_fk", + "tableFrom": "content_report", + "tableTo": "grade", + "columnsFrom": [ + "grade_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.eval_run": { + "name": "eval_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "suite": { + "name": "suite", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "false_fail_rate": { + "name": "false_fail_rate", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "accuracy": { + "name": "accuracy", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "total": { + "name": "total", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "passed": { + "name": "passed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "model_version": { + "name": "model_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt_version": { + "name": "prompt_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "git_sha": { + "name": "git_sha", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.grade": { + "name": "grade", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "response_id": { + "name": "response_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "verdict": { + "name": "verdict", + "type": "grade_verdict", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "misconception_tag": { + "name": "misconception_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "confidence": { + "name": "confidence", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "grade_response_id_response_id_fk": { + "name": "grade_response_id_response_id_fk", + "tableFrom": "grade", + "tableTo": "response", + "columnsFrom": [ + "response_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invite": { + "name": "invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'learner'" + }, + "invited_by": { + "name": "invited_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invite_invited_by_user_id_fk": { + "name": "invite_invited_by_user_id_fk", + "tableFrom": "invite", + "tableTo": "user", + "columnsFrom": [ + "invited_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.job": { + "name": "job", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "job_idempotency_key_unique": { + "name": "job_idempotency_key_unique", + "nullsNotDistinct": false, + "columns": [ + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.journey_instance": { + "name": "journey_instance", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "blueprint_id": { + "name": "blueprint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "journey_instance_user_id_user_id_fk": { + "name": "journey_instance_user_id_user_id_fk", + "tableFrom": "journey_instance", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "journey_instance_blueprint_id_blueprint_id_fk": { + "name": "journey_instance_blueprint_id_blueprint_id_fk", + "tableFrom": "journey_instance", + "tableTo": "blueprint", + "columnsFrom": [ + "blueprint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lesson": { + "name": "lesson", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "blueprint_id": { + "name": "blueprint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "est_minutes": { + "name": "est_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "locale": { + "name": "locale", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "depth": { + "name": "depth", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'standard'" + }, + "age_group": { + "name": "age_group", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'adult'" + } + }, + "indexes": {}, + "foreignKeys": { + "lesson_blueprint_id_blueprint_id_fk": { + "name": "lesson_blueprint_id_blueprint_id_fk", + "tableFrom": "lesson", + "tableTo": "blueprint", + "columnsFrom": [ + "blueprint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mastery": { + "name": "mastery", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "concept_id": { + "name": "concept_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0.5 + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "next_review": { + "name": "next_review", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mastery_user_id_user_id_fk": { + "name": "mastery_user_id_user_id_fk", + "tableFrom": "mastery", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "mastery_concept_id_concept_id_fk": { + "name": "mastery_concept_id_concept_id_fk", + "tableFrom": "mastery", + "tableTo": "concept", + "columnsFrom": [ + "concept_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mastery_user_id_concept_id_pk": { + "name": "mastery_user_id_concept_id_pk", + "columns": [ + "user_id", + "concept_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.misconception": { + "name": "misconception", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "concept_id": { + "name": "concept_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correction": { + "name": "correction", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verify_status": { + "name": "verify_status", + "type": "verify_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + } + }, + "indexes": {}, + "foreignKeys": { + "misconception_concept_id_concept_id_fk": { + "name": "misconception_concept_id_concept_id_fk", + "tableFrom": "misconception", + "tableTo": "concept", + "columnsFrom": [ + "concept_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.novel_misconduct_queue": { + "name": "novel_misconduct_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "grade_id": { + "name": "grade_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "response_text": { + "name": "response_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "evidence_span": { + "name": "evidence_span", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processed": { + "name": "processed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "novel_misconduct_queue_grade_id_grade_id_fk": { + "name": "novel_misconduct_queue_grade_id_grade_id_fk", + "tableFrom": "novel_misconduct_queue", + "tableTo": "grade", + "columnsFrom": [ + "grade_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "novel_misconduct_queue_checkpoint_id_checkpoint_id_fk": { + "name": "novel_misconduct_queue_checkpoint_id_checkpoint_id_fk", + "tableFrom": "novel_misconduct_queue", + "tableTo": "checkpoint", + "columnsFrom": [ + "checkpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password_reset_token": { + "name": "password_reset_token", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_reset_token_user_id_user_id_fk": { + "name": "password_reset_token_user_id_user_id_fk", + "tableFrom": "password_reset_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.response": { + "name": "response", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "self_confidence": { + "name": "self_confidence", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ts": { + "name": "ts", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "response_user_id_user_id_fk": { + "name": "response_user_id_user_id_fk", + "tableFrom": "response", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "response_checkpoint_id_checkpoint_id_fk": { + "name": "response_checkpoint_id_checkpoint_id_fk", + "tableFrom": "response", + "tableTo": "checkpoint", + "columnsFrom": [ + "checkpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.segment": { + "name": "segment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "body_json": { + "name": "body_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source_chunk_ids": { + "name": "source_chunk_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "verify_status": { + "name": "verify_status", + "type": "verify_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "content_version": { + "name": "content_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "difficulty_level": { + "name": "difficulty_level", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "segment_lesson_id_lesson_id_fk": { + "name": "segment_lesson_id_lesson_id_fk", + "tableFrom": "segment", + "tableTo": "lesson", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "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": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.site_config": { + "name": "site_config", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "varchar(100)", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.source_chunk": { + "name": "source_chunk", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "doc_ref": { + "name": "doc_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'learner'" + }, + "locale": { + "name": "locale", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preferences": { + "name": "preferences", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification_token": { + "name": "verification_token", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verification_token_identifier_token_pk": { + "name": "verification_token_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verify_report": { + "name": "verify_report", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verdict": { + "name": "verdict", + "type": "verify_verdict", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "confidence": { + "name": "confidence", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "model_version": { + "name": "model_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.blueprint_status": { + "name": "blueprint_status", + "schema": "public", + "values": [ + "draft", + "verifying", + "published", + "flagged" + ] + }, + "public.checkpoint_kind": { + "name": "checkpoint_kind", + "schema": "public", + "values": [ + "predict", + "explain", + "solve" + ] + }, + "public.content_report_reason": { + "name": "content_report_reason", + "schema": "public", + "values": [ + "grade_wrong", + "content_error", + "other" + ] + }, + "public.grade_verdict": { + "name": "grade_verdict", + "schema": "public", + "values": [ + "mastered", + "partial", + "misconception", + "uncertain" + ] + }, + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": [ + "pending", + "running", + "done", + "failed" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "learner", + "admin", + "suspended" + ] + }, + "public.verify_status": { + "name": "verify_status", + "schema": "public", + "values": [ + "pending", + "pass", + "fail", + "uncertain" + ] + }, + "public.verify_verdict": { + "name": "verify_verdict", + "schema": "public", + "values": [ + "pass", + "fail", + "uncertain" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/migrations/meta/0017_snapshot.json b/drizzle/migrations/meta/0017_snapshot.json new file mode 100644 index 0000000..952143f --- /dev/null +++ b/drizzle/migrations/meta/0017_snapshot.json @@ -0,0 +1,1797 @@ +{ + "id": "f5416d5c-4820-4ef1-8215-df18196ab669", + "prevId": "e057ff8f-3be2-4981-b3f5-03f19af58df6", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "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": { + "account_provider_provider_account_id_pk": { + "name": "account_provider_provider_account_id_pk", + "columns": [ + "provider", + "provider_account_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blueprint_theme": { + "name": "blueprint_theme", + "schema": "", + "columns": { + "blueprint_id": { + "name": "blueprint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "theme_id": { + "name": "theme_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "blueprint_theme_blueprint_id_blueprint_id_fk": { + "name": "blueprint_theme_blueprint_id_blueprint_id_fk", + "tableFrom": "blueprint_theme", + "tableTo": "blueprint", + "columnsFrom": [ + "blueprint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "blueprint_theme_theme_id_theme_id_fk": { + "name": "blueprint_theme_theme_id_theme_id_fk", + "tableFrom": "blueprint_theme", + "tableTo": "theme", + "columnsFrom": [ + "theme_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "blueprint_theme_blueprint_id_theme_id_pk": { + "name": "blueprint_theme_blueprint_id_theme_id_pk", + "columns": [ + "blueprint_id", + "theme_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blueprint": { + "name": "blueprint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "intent_key": { + "name": "intent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_version": { + "name": "model_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_version": { + "name": "content_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "prompt_version": { + "name": "prompt_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stale_at": { + "name": "stale_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "blueprint_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "blueprint_intent_key_unique": { + "name": "blueprint_intent_key_unique", + "nullsNotDistinct": false, + "columns": [ + "intent_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.checkpoint": { + "name": "checkpoint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "segment_id": { + "name": "segment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "checkpoint_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "reference_answer": { + "name": "reference_answer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rubric_json": { + "name": "rubric_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "verify_status": { + "name": "verify_status", + "type": "verify_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "content_version": { + "name": "content_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "checkpoint_segment_id_segment_id_fk": { + "name": "checkpoint_segment_id_segment_id_fk", + "tableFrom": "checkpoint", + "tableTo": "segment", + "columnsFrom": [ + "segment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.concept": { + "name": "concept", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "blueprint_id": { + "name": "blueprint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "retrieval_ctx_ref": { + "name": "retrieval_ctx_ref", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "concept_blueprint_id_blueprint_id_fk": { + "name": "concept_blueprint_id_blueprint_id_fk", + "tableFrom": "concept", + "tableTo": "blueprint", + "columnsFrom": [ + "blueprint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.content_report": { + "name": "content_report", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "grade_id": { + "name": "grade_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "content_report_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "content_report_checkpoint_id_checkpoint_id_fk": { + "name": "content_report_checkpoint_id_checkpoint_id_fk", + "tableFrom": "content_report", + "tableTo": "checkpoint", + "columnsFrom": [ + "checkpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "content_report_grade_id_grade_id_fk": { + "name": "content_report_grade_id_grade_id_fk", + "tableFrom": "content_report", + "tableTo": "grade", + "columnsFrom": [ + "grade_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.eval_run": { + "name": "eval_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "suite": { + "name": "suite", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "false_fail_rate": { + "name": "false_fail_rate", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "accuracy": { + "name": "accuracy", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "total": { + "name": "total", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "passed": { + "name": "passed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "model_version": { + "name": "model_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt_version": { + "name": "prompt_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "git_sha": { + "name": "git_sha", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.grade": { + "name": "grade", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "response_id": { + "name": "response_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "verdict": { + "name": "verdict", + "type": "grade_verdict", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "misconception_tag": { + "name": "misconception_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "confidence": { + "name": "confidence", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "grade_response_id_response_id_fk": { + "name": "grade_response_id_response_id_fk", + "tableFrom": "grade", + "tableTo": "response", + "columnsFrom": [ + "response_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invite": { + "name": "invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'learner'" + }, + "invited_by": { + "name": "invited_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invite_invited_by_user_id_fk": { + "name": "invite_invited_by_user_id_fk", + "tableFrom": "invite", + "tableTo": "user", + "columnsFrom": [ + "invited_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.job": { + "name": "job", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "job_idempotency_key_unique": { + "name": "job_idempotency_key_unique", + "nullsNotDistinct": false, + "columns": [ + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.journey_instance": { + "name": "journey_instance", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "blueprint_id": { + "name": "blueprint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "journey_instance_user_id_user_id_fk": { + "name": "journey_instance_user_id_user_id_fk", + "tableFrom": "journey_instance", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "journey_instance_blueprint_id_blueprint_id_fk": { + "name": "journey_instance_blueprint_id_blueprint_id_fk", + "tableFrom": "journey_instance", + "tableTo": "blueprint", + "columnsFrom": [ + "blueprint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lesson": { + "name": "lesson", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "blueprint_id": { + "name": "blueprint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "est_minutes": { + "name": "est_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "locale": { + "name": "locale", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "depth": { + "name": "depth", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'standard'" + }, + "age_group": { + "name": "age_group", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'adult'" + } + }, + "indexes": {}, + "foreignKeys": { + "lesson_blueprint_id_blueprint_id_fk": { + "name": "lesson_blueprint_id_blueprint_id_fk", + "tableFrom": "lesson", + "tableTo": "blueprint", + "columnsFrom": [ + "blueprint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mastery": { + "name": "mastery", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "concept_id": { + "name": "concept_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0.5 + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "next_review": { + "name": "next_review", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mastery_user_id_user_id_fk": { + "name": "mastery_user_id_user_id_fk", + "tableFrom": "mastery", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "mastery_concept_id_concept_id_fk": { + "name": "mastery_concept_id_concept_id_fk", + "tableFrom": "mastery", + "tableTo": "concept", + "columnsFrom": [ + "concept_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mastery_user_id_concept_id_pk": { + "name": "mastery_user_id_concept_id_pk", + "columns": [ + "user_id", + "concept_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.misconception": { + "name": "misconception", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "concept_id": { + "name": "concept_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correction": { + "name": "correction", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verify_status": { + "name": "verify_status", + "type": "verify_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + } + }, + "indexes": {}, + "foreignKeys": { + "misconception_concept_id_concept_id_fk": { + "name": "misconception_concept_id_concept_id_fk", + "tableFrom": "misconception", + "tableTo": "concept", + "columnsFrom": [ + "concept_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.novel_misconduct_queue": { + "name": "novel_misconduct_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "grade_id": { + "name": "grade_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "response_text": { + "name": "response_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "evidence_span": { + "name": "evidence_span", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processed": { + "name": "processed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "novel_misconduct_queue_grade_id_grade_id_fk": { + "name": "novel_misconduct_queue_grade_id_grade_id_fk", + "tableFrom": "novel_misconduct_queue", + "tableTo": "grade", + "columnsFrom": [ + "grade_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "novel_misconduct_queue_checkpoint_id_checkpoint_id_fk": { + "name": "novel_misconduct_queue_checkpoint_id_checkpoint_id_fk", + "tableFrom": "novel_misconduct_queue", + "tableTo": "checkpoint", + "columnsFrom": [ + "checkpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password_reset_token": { + "name": "password_reset_token", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_reset_token_user_id_user_id_fk": { + "name": "password_reset_token_user_id_user_id_fk", + "tableFrom": "password_reset_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.response": { + "name": "response", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "self_confidence": { + "name": "self_confidence", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ts": { + "name": "ts", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "response_user_id_user_id_fk": { + "name": "response_user_id_user_id_fk", + "tableFrom": "response", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "response_checkpoint_id_checkpoint_id_fk": { + "name": "response_checkpoint_id_checkpoint_id_fk", + "tableFrom": "response", + "tableTo": "checkpoint", + "columnsFrom": [ + "checkpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.segment": { + "name": "segment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "lesson_id": { + "name": "lesson_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "body_json": { + "name": "body_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source_chunk_ids": { + "name": "source_chunk_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "verify_status": { + "name": "verify_status", + "type": "verify_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "content_version": { + "name": "content_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "difficulty_level": { + "name": "difficulty_level", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "segment_lesson_id_lesson_id_fk": { + "name": "segment_lesson_id_lesson_id_fk", + "tableFrom": "segment", + "tableTo": "lesson", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "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": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.site_config": { + "name": "site_config", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "varchar(100)", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.source_chunk": { + "name": "source_chunk", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "doc_ref": { + "name": "doc_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.theme": { + "name": "theme", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label_en": { + "name": "label_en", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label_fr": { + "name": "label_fr", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "theme_slug_unique": { + "name": "theme_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'learner'" + }, + "locale": { + "name": "locale", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preferences": { + "name": "preferences", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification_token": { + "name": "verification_token", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verification_token_identifier_token_pk": { + "name": "verification_token_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verify_report": { + "name": "verify_report", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verdict": { + "name": "verdict", + "type": "verify_verdict", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "confidence": { + "name": "confidence", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "model_version": { + "name": "model_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.blueprint_status": { + "name": "blueprint_status", + "schema": "public", + "values": [ + "draft", + "verifying", + "published", + "flagged" + ] + }, + "public.checkpoint_kind": { + "name": "checkpoint_kind", + "schema": "public", + "values": [ + "predict", + "explain", + "solve" + ] + }, + "public.content_report_reason": { + "name": "content_report_reason", + "schema": "public", + "values": [ + "grade_wrong", + "content_error", + "other" + ] + }, + "public.grade_verdict": { + "name": "grade_verdict", + "schema": "public", + "values": [ + "mastered", + "partial", + "misconception", + "uncertain" + ] + }, + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": [ + "pending", + "running", + "done", + "failed" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "learner", + "admin", + "suspended" + ] + }, + "public.verify_status": { + "name": "verify_status", + "schema": "public", + "values": [ + "pending", + "pass", + "fail", + "uncertain" + ] + }, + "public.verify_verdict": { + "name": "verify_verdict", + "schema": "public", + "values": [ + "pass", + "fail", + "uncertain" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json index 2f52d9f..4362297 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -43,6 +43,90 @@ "when": 1782070177922, "tag": "0005_site_email", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1782074310619, + "tag": "0006_slim_ultragirl", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1750550400000, + "tag": "0007_lesson_locale", + "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1782133019007, + "tag": "0008_noisy_cloak", + "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1782133583085, + "tag": "0009_unique_wallflower", + "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1782200000000, + "tag": "0010_lesson_depth", + "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1782138081002, + "tag": "0011_smooth_thanos", + "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1782500000000, + "tag": "0012_user_preferences", + "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1782310455008, + "tag": "0013_illegal_random", + "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1782312574334, + "tag": "0014_faulty_namor", + "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1782313119046, + "tag": "0015_mighty_prowler", + "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1782400000000, + "tag": "0016_theme_map", + "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1782337567021, + "tag": "0017_married_betty_brant", + "breakpoints": true } ] } \ No newline at end of file diff --git a/jobs/bootstrap.ts b/jobs/bootstrap.ts new file mode 100644 index 0000000..a796778 --- /dev/null +++ b/jobs/bootstrap.ts @@ -0,0 +1,30 @@ +/** + * Worker entrypoint. Stubs the `server-only` import guard before loading any + * src/lib code, then hands off to the real worker registry. + * + * Why: src/lib/** modules begin with `import 'server-only'` to keep RSC-only + * code out of client bundles. That package throws when required outside Next's + * React Server bundler. Workers are server code but run under plain Node/tsx, + * so we resolve `server-only` to a no-op here — and ONLY here, so the app's + * build-time guard is untouched. Mirrors the vitest alias in vitest.config.ts. + */ +import Module, { createRequire } from 'module'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const here = dirname(fileURLToPath(import.meta.url)); +const NOOP = join(here, 'server-only.noop.cjs'); + +type ResolveFn = (request: string, ...rest: unknown[]) => string; +const moduleInternals = Module as unknown as { _resolveFilename: ResolveFn }; +const originalResolve = moduleInternals._resolveFilename; + +moduleInternals._resolveFilename = function (request, ...rest) { + if (request === 'server-only') return NOOP; + return originalResolve.call(this, request, ...rest); +}; + +// Load the worker graph *after* the stub is installed (require keeps it +// synchronous — tsx compiles this file to CJS, where top-level await is unavailable). +const require = createRequire(import.meta.url); +require('./index'); diff --git a/jobs/generate-lesson-job.ts b/jobs/generate-lesson-job.ts index 843ad7f..3ec2e25 100644 --- a/jobs/generate-lesson-job.ts +++ b/jobs/generate-lesson-job.ts @@ -6,13 +6,14 @@ * On failure: increments attempt count; sets status to 'failed' after processing. */ import { Worker } from 'bullmq'; -import { eq } from 'drizzle-orm'; +import { eq, and } from 'drizzle-orm'; import { db } from '../src/lib/db'; import { jobs, blueprints, concepts, lessons } from '../src/lib/db/schema'; import { generateLesson } from '../src/lib/generation/generate-lesson'; import { verifyLesson } from '../src/lib/verification/verify-content'; import { getLessonResponse } from '../src/lib/db/queries'; import { setCachedLesson } from '../src/lib/cache/lesson'; +import { appendStreamSegment, markStreamDone } from '../src/lib/cache/stream'; import { enqueuePromoteBlueprint } from '../src/lib/jobs/queue'; import type { GenerateLessonJobData } from '../src/lib/jobs/queue'; @@ -23,7 +24,7 @@ const connection = { const worker = new Worker( 'generate-lesson', async (job) => { - const { intentKey, blueprintId, idempotencyKey } = job.data; + const { intentKey, blueprintId, idempotencyKey, locale = 'en', depth = 'standard', ageGroup = 'adult', difficultyLevel = 1, ord = 0 } = job.data; // 1. Idempotency check — find the job row by idempotency key const [jobRow] = await db @@ -34,9 +35,9 @@ const worker = new Worker( if (jobRow?.status === 'done') { // Already completed — warm cache if needed and return - const cached = await getLessonResponse(intentKey); + const cached = await getLessonResponse(intentKey, undefined, locale, depth as import('../src/lib/generation/depth').LessonDepth, ageGroup as import('../src/schemas/preferences').AgeGroup); if (cached?.state === 'ready') { - await setCachedLesson(intentKey, cached); + await setCachedLesson(intentKey, locale, cached, depth as import('../src/lib/generation/depth').LessonDepth, ageGroup as import('../src/schemas/preferences').AgeGroup); } return; } @@ -47,15 +48,31 @@ const worker = new Worker( .set({ status: 'running', attempts: (jobRow?.attempts ?? 0) + 1 }) .where(eq(jobs.id, idempotencyKey)); - // 2. Check lesson doesn't already exist - const existing = await getLessonResponse(intentKey); - if (existing?.state === 'ready') { - await setCachedLesson(intentKey, existing); + // 2. Check lesson at this ord doesn't already exist + const [existingLesson] = await db + .select({ id: lessons.id }) + .from(lessons) + .where( + and( + eq(lessons.blueprintId, blueprintId), + eq(lessons.locale, locale), + eq(lessons.depth, depth), + eq(lessons.ageGroup, ageGroup), + eq(lessons.ord, ord), + ), + ) + .limit(1); + + if (existingLesson) { + const cached = await getLessonResponse(intentKey, undefined, locale, depth as import('../src/lib/generation/depth').LessonDepth, ageGroup as import('../src/schemas/preferences').AgeGroup); + if (cached?.state === 'ready') { + await setCachedLesson(intentKey, locale, cached, depth as import('../src/lib/generation/depth').LessonDepth, ageGroup as import('../src/schemas/preferences').AgeGroup); + } await db.update(jobs).set({ status: 'done' }).where(eq(jobs.id, idempotencyKey)); return; } - // 3. Fetch blueprint + concepts + // 3. Fetch blueprint + concepts at this ord const [blueprint] = await db .select() .from(blueprints) @@ -64,32 +81,57 @@ const worker = new Worker( if (!blueprint) throw new Error(`Blueprint not found: ${blueprintId}`); + // For ord=0 use all concepts (backward compat); for ord>0 use concepts at that ord. const conceptRows = await db .select({ id: concepts.id, name: concepts.name, ord: concepts.ord, retrievalCtxRef: concepts.retrievalCtxRef }) .from(concepts) - .where(eq(concepts.blueprintId, blueprintId)); + .where( + ord === 0 + ? eq(concepts.blueprintId, blueprintId) + : and(eq(concepts.blueprintId, blueprintId), eq(concepts.ord, ord)), + ); - if (conceptRows.length === 0) throw new Error(`No concepts for blueprint: ${blueprintId}`); + if (conceptRows.length === 0) throw new Error(`No concepts at ord=${ord} for blueprint: ${blueprintId}`); - // 4. Generate lesson + // 4. Generate lesson — onSegment pushes each segment to Redis as it's persisted, + // so SSE clients receive progressive delivery without waiting for the full batch. const { lessonId } = await generateLesson({ blueprintId, - lessonOrd: 0, + lessonOrd: ord, topicTitle: blueprint.title, conceptsToGenerate: conceptRows.map((c) => ({ id: c.id, name: c.name, retrievalCtxRef: c.retrievalCtxRef, })), + locale, + depth: depth as import('../src/lib/generation/depth').LessonDepth, + ageGroup: ageGroup as import('../src/schemas/preferences').AgeGroup, + difficultyLevel: difficultyLevel as 1 | 2 | 3, + onSegment: (seg) => + appendStreamSegment( + intentKey, locale, seg, + depth as import('../src/lib/generation/depth').LessonDepth, + ageGroup as import('../src/schemas/preferences').AgeGroup, + ord, + ), }); + // Signal SSE clients that the stream is complete. + await markStreamDone( + intentKey, locale, + depth as import('../src/lib/generation/depth').LessonDepth, + ageGroup as import('../src/schemas/preferences').AgeGroup, + ord, + ); + // 5. T1 verification (non-blocking for serve — T2 + promotion handled by promote-blueprint job) await verifyLesson({ lessonId, runT2: false }); - // 6. Write to Redis cache - const lesson = await getLessonResponse(intentKey); + // 6. Write to Redis cache (position-keyed) + const lesson = await getLessonResponse(intentKey, undefined, locale, depth as import('../src/lib/generation/depth').LessonDepth, ageGroup as import('../src/schemas/preferences').AgeGroup); if (lesson?.state === 'ready') { - await setCachedLesson(intentKey, lesson); + await setCachedLesson(intentKey, locale, lesson, depth as import('../src/lib/generation/depth').LessonDepth, ageGroup as import('../src/schemas/preferences').AgeGroup, ord); } // 7. Update job ledger diff --git a/jobs/index.ts b/jobs/index.ts index 111fae5..d1a405f 100644 --- a/jobs/index.ts +++ b/jobs/index.ts @@ -1,13 +1,15 @@ import generateLessonWorker from './generate-lesson-job'; import promoteBlueprintWorker from './promote-blueprint-job'; +import regenerateBlueprintWorker from './regenerate-blueprint-job'; -console.log('[workers] Running: generate-lesson, promote-blueprint'); +console.log('[workers] Running: generate-lesson, promote-blueprint, regenerate-blueprint'); async function shutdown(signal: string) { console.log(`[workers] ${signal} received — draining and closing workers...`); await Promise.all([ generateLessonWorker.close(), promoteBlueprintWorker.close(), + regenerateBlueprintWorker.close(), ]); console.log('[workers] Shutdown complete.'); process.exit(0); diff --git a/jobs/promote-blueprint-job.ts b/jobs/promote-blueprint-job.ts index 6d5467f..ffcee0d 100644 --- a/jobs/promote-blueprint-job.ts +++ b/jobs/promote-blueprint-job.ts @@ -100,7 +100,7 @@ const worker = new Worker( } // 6. Invalidate cache so next request fetches fresh lesson with updated verify status - await invalidateCachedLesson(intentKey); + await invalidateCachedLesson(intentKey, 'en'); await db.update(jobs).set({ status: 'done' }).where(eq(jobs.id, idempotencyKey)); }, diff --git a/jobs/regenerate-blueprint-job.ts b/jobs/regenerate-blueprint-job.ts new file mode 100644 index 0000000..cd8317b --- /dev/null +++ b/jobs/regenerate-blueprint-job.ts @@ -0,0 +1,165 @@ +/** + * BullMQ worker: regenerate all lessons for a stale blueprint at a new contentVersion. + * + * Idempotent: idempotency key encodes `blueprintId:targetContentVersion` so + * retries never double-bill. Serves old content until regeneration completes. + */ +import { Worker } from 'bullmq'; +import { eq, and } from 'drizzle-orm'; +import { db } from '../src/lib/db'; +import { jobs, blueprints, concepts, lessons } from '../src/lib/db/schema'; +import { generateLesson } from '../src/lib/generation/generate-lesson'; +import { verifyLesson } from '../src/lib/verification/verify-content'; +import { clearBlueprintStale } from '../src/lib/generation/staleness'; +import { getLessonResponse } from '../src/lib/db/queries'; +import { setCachedLesson } from '../src/lib/cache/lesson'; +import { enqueuePromoteBlueprint } from '../src/lib/jobs/queue'; +import type { RegenerateBlueprintJobData } from '../src/lib/jobs/queue'; + +const connection = { + url: process.env.REDIS_URL ?? 'redis://localhost:6379', +}; + +const worker = new Worker( + 'regenerate-blueprint', + async (job) => { + const { blueprintId, intentKey, targetContentVersion, idempotencyKey } = job.data; + + // 1. Idempotency check + const [jobRow] = await db + .select({ id: jobs.id, status: jobs.status, attempts: jobs.attempts }) + .from(jobs) + .where(eq(jobs.id, idempotencyKey)) + .limit(1); + + if (jobRow?.status === 'done') return; + + await db + .update(jobs) + .set({ status: 'running', attempts: (jobRow?.attempts ?? 0) + 1 }) + .where(eq(jobs.id, idempotencyKey)); + + // 2. Verify the blueprint still needs this version (may have been superseded) + const [bp] = await db + .select({ id: blueprints.id, contentVersion: blueprints.contentVersion, title: blueprints.title }) + .from(blueprints) + .where(eq(blueprints.id, blueprintId)) + .limit(1); + + if (!bp) throw new Error(`Blueprint not found: ${blueprintId}`); + + // If the blueprint has already been rebuilt at or past targetContentVersion, skip. + if (bp.contentVersion > targetContentVersion) { + await db.update(jobs).set({ status: 'done' }).where(eq(jobs.id, idempotencyKey)); + return; + } + + // 3. Load concepts + const conceptRows = await db + .select({ id: concepts.id, name: concepts.name, ord: concepts.ord, retrievalCtxRef: concepts.retrievalCtxRef }) + .from(concepts) + .where(eq(concepts.blueprintId, blueprintId)); + + if (conceptRows.length === 0) throw new Error(`No concepts for blueprint: ${blueprintId}`); + + // 4. Find all unique (locale, depth, ageGroup) variants already generated for this blueprint + // so we regenerate the same set. + const existingVariants = await db + .select({ locale: lessons.locale, depth: lessons.depth, ageGroup: lessons.ageGroup }) + .from(lessons) + .where(eq(lessons.blueprintId, blueprintId)); + + // Dedupe variants; always include default. + const variantSet = new Map(); + variantSet.set('en:standard:adult', { locale: 'en', depth: 'standard', ageGroup: 'adult' }); + for (const v of existingVariants) { + variantSet.set(`${v.locale}:${v.depth}:${v.ageGroup}`, v); + } + + // 5. Regenerate each variant + for (const variant of variantSet.values()) { + const { locale, depth, ageGroup } = variant; + const { lessonId } = await generateLesson({ + blueprintId, + lessonOrd: 0, + topicTitle: bp.title, + conceptsToGenerate: conceptRows.map((c) => ({ + id: c.id, + name: c.name, + retrievalCtxRef: c.retrievalCtxRef, + })), + locale, + depth: depth as import('../src/lib/generation/depth').LessonDepth, + ageGroup: ageGroup as import('../src/schemas/preferences').AgeGroup, + }); + + // T1 verify (T2 handled by promote job) + await verifyLesson({ lessonId, runT2: false }); + + // Update cache + const lesson = await getLessonResponse( + intentKey, undefined, locale, + depth as import('../src/lib/generation/depth').LessonDepth, + ageGroup as import('../src/schemas/preferences').AgeGroup, + ); + if (lesson?.state === 'ready') { + await setCachedLesson( + intentKey, locale, lesson, + depth as import('../src/lib/generation/depth').LessonDepth, + ageGroup as import('../src/schemas/preferences').AgeGroup, + ); + } + } + + // 6. Clear stale flag + stamp current signature + await clearBlueprintStale(blueprintId); + + // 7. Bump contentVersion to targetContentVersion (done after generation succeeds) + await db + .update(blueprints) + .set({ contentVersion: targetContentVersion }) + .where(and(eq(blueprints.id, blueprintId))); + + // 8. Mark job done + await db.update(jobs).set({ status: 'done' }).where(eq(jobs.id, idempotencyKey)); + + // 9. Enqueue promotion (T2 + misconceptions) + const promoteKey = `promote-blueprint:${blueprintId}:v${targetContentVersion}`; + const [existingPromote] = await db + .select({ id: jobs.id }) + .from(jobs) + .where(eq(jobs.id, promoteKey)) + .limit(1); + + if (!existingPromote) { + const [promoteJobRow] = await db + .insert(jobs) + .values({ + type: 'promote-blueprint', + idempotencyKey: promoteKey, + status: 'pending', + payloadJson: { blueprintId, intentKey }, + }) + .returning({ id: jobs.id }); + + await enqueuePromoteBlueprint({ + blueprintId, + intentKey, + idempotencyKey: promoteJobRow.id, + }); + } + }, + { connection, concurrency: 1 }, +); + +worker.on('failed', async (job, err) => { + if (job?.data.idempotencyKey) { + await db + .update(jobs) + .set({ status: 'failed' }) + .where(eq(jobs.id, job.data.idempotencyKey)); + } + console.error('[regenerate-blueprint] job failed:', err); +}); + +export default worker; diff --git a/jobs/server-only.noop.cjs b/jobs/server-only.noop.cjs new file mode 100644 index 0000000..8328b6e --- /dev/null +++ b/jobs/server-only.noop.cjs @@ -0,0 +1,5 @@ +// Empty stand-in for the `server-only` package inside the worker runtime. +// `server-only` throws when imported outside Next's RSC bundler; BullMQ workers +// are legitimate server code running under plain Node/tsx, so we neutralize it. +// Scoped to the worker process via jobs/bootstrap.ts — Next keeps the real guard. +module.exports = {}; diff --git a/messages/en.json b/messages/en.json new file mode 100644 index 0000000..5f08c64 --- /dev/null +++ b/messages/en.json @@ -0,0 +1,586 @@ +{ + "lesson": { + "ariaLesson": "Lesson", + "ariaProgress": "Lesson progress", + "reading": "Reading", + "segments": "{count, plural, one {# segment} other {# segments}}", + "segmentLocked": "Complete the previous checkpoint to continue.", + "checkpoint": { + "predict": "Predict", + "explain": "Explain", + "solve": "Solve", + "placeholder": "Write your answer here…", + "checking": "Checking…", + "submitted": "Submitted", + "confidenceLegend": "Before you submit — how sure are you?", + "notSure": "Not sure", + "thinkSo": "Think so", + "confident": "Confident", + "notSureAria": "Not sure about my answer", + "thinkSoAria": "I think my answer is right", + "confidentAria": "I am confident in my answer" + }, + "print": "Export PDF", + "considering": "Considering…", + "empty": "This lesson has no segments yet. Check back shortly — content is still being prepared.", + "outline": { + "intro": "What you'll explore", + "preparing": "Generating your lesson…", + "streaming": "Writing your lesson…", + "streamingLabel": "Lesson content loading" + }, + "tangent": { + "placeholder": "Curious about something?", + "ariaInput": "Curiosity question", + "ariaAsk": "Ask", + "considering": "Looking into it…" + }, + "journeyProgress": "Lesson {current} of {total}", + "complete": { + "ariaLabel": "Lesson complete", + "heading": "Lesson complete.", + "saved": "Progress saved.", + "saving": "Saving…", + "markComplete": "Mark complete", + "nextLesson": "Next lesson →", + "deepen": "Go deeper →", + "journeyDone": "Journey complete.", + "journeyDoneNote": "You've finished all lessons in this journey." + } + }, + "review": { + "title": "Daily Review", + "aria": "Daily review session", + "loading": "Loading your review…", + "empty": "Nothing due for review. Check back tomorrow.", + "progress": "{current} of {total}", + "considering": "Considering…", + "next": "Next →", + "finish": "Finish", + "complete": { + "heading": "Session complete", + "reviewed": "{count, plural, one {# concept reviewed} other {# concepts reviewed}}", + "netDelta": "Net mastery: {delta}", + "nothingDue": "You're all caught up.", + "backHome": "Back to learning" + }, + "delta": { + "up": "↑ {pct}%", + "down": "↓ {pct}%", + "same": "unchanged" + }, + "emptyUntil": "All caught up. Your next review is on {date}.", + "emptyNoHistory": "No reviews yet. Complete a lesson to start building your review queue." + }, + "nav": { + "learn": "Learn", + "review": "Review", + "mastery": "Mastery", + "map": "Map", + "signIn": "Sign in", + "signOut": "Sign out", + "profile": "Profile", + "admin": "Admin" + }, + "home": { + "eyebrow": "Curiosity-led mastery", + "title": "What do you want to understand?", + "lede": "Tell us what you're curious about.", + "fieldLabel": "Your learning intent", + "placeholder": "e.g. how vaccines work", + "examplesLabel": "Or try one of these", + "submit": "Start learning", + "submitting": "Finding your lesson…", + "error": "Something went wrong. Try again.", + "examples": [ + "Why ice is slippery", + "How a gyroscope stays upright", + "Why the sky is blue", + "What temperature really is", + "How noise-cancelling headphones work", + "Why light speed is a limit", + "How lasers work", + "Why magnets attract", + "What causes friction", + "How a microwave heats food", + "Why glass is transparent", + "How superconductors work", + "What quantum entanglement is", + "Why mirrors flip left and right", + "How sound travels", + "What makes a rainbow", + "Why hot air rises", + "How a nuclear reactor works", + "What dark matter might be", + "Why time slows near light speed", + "How black holes form", + "Why the moon causes tides", + "What a neutron star is", + "How stars make elements", + "Why Mars is red", + "How rockets reach orbit", + "What the Big Bang was", + "Why Pluto isn't a planet", + "How GPS uses relativity", + "What causes solar eclipses", + "How telescopes see the past", + "Why galaxies spiral", + "What a light-year measures", + "How the sun produces energy", + "Why space is cold but sunlight burns", + "What causes the seasons", + "How comets get their tails", + "Why the universe is expanding", + "What exoplanets are", + "How auroras form", + "How vaccines work", + "Why onions make you cry", + "How memory is stored", + "Why we dream", + "How muscles grow", + "What causes aging", + "How antibiotics kill bacteria", + "Why we get fevers", + "How the immune system learns", + "What DNA actually does", + "How nerves send signals", + "Why we yawn", + "How the heart keeps rhythm", + "What gut bacteria do", + "How wounds heal", + "Why we need sleep", + "How eyes detect color", + "What causes allergies", + "How cells divide", + "Why exercise builds endurance", + "Why soap cleans", + "How batteries store energy", + "What makes something acidic", + "How glass is made", + "Why metals conduct electricity", + "How fireworks get their colors", + "What rust really is", + "How catalysts speed reactions", + "Why oil and water don't mix", + "How plastic is made", + "What pH measures", + "How fermentation works", + "Why diamonds are hard", + "How soap bubbles form", + "What makes chili peppers hot", + "How dry ice works", + "Why bread rises", + "How perfume spreads", + "What alloys are", + "How carbon dating works", + "TCP congestion control", + "How encryption keeps secrets", + "What a database index does", + "How neural networks learn", + "Why passwords get hashed", + "How the internet routes data", + "What a compiler does", + "How GPS finds your location", + "Why computers use binary", + "How a CPU executes code", + "What caching really solves", + "How Wi-Fi carries data", + "What blockchain actually is", + "How search engines rank pages", + "Why RAM is faster than disk", + "How image compression works", + "What an API is", + "How public-key crypto works", + "Why software has bugs", + "How machine translation works", + "Why prime numbers matter", + "What calculus measures", + "How probability works", + "Why pi is everywhere", + "What infinity really means", + "What a derivative tells you", + "Why zero was revolutionary", + "How statistics can mislead", + "What exponential growth means", + "Why some problems are unsolvable", + "How compound interest grows", + "What a logarithm is", + "Why the bell curve appears", + "How fractals are made", + "What Bayes' theorem does", + "Why we can't divide by zero", + "How GPS triangulates position", + "What a matrix represents", + "Why randomness is hard", + "How central banks set rates", + "What causes inflation", + "How stock markets work", + "Why money has value", + "What a recession is", + "How taxes shape behavior", + "Why prices rise and fall", + "What supply and demand means", + "How insurance spreads risk", + "Why trade benefits both sides", + "What GDP measures", + "How credit scores work", + "Why bubbles form and burst", + "How exchange rates move", + "Why monopolies are risky", + "What a bond is", + "How venture capital works", + "Why diversification matters", + "What game theory explains", + "How the printing press changed Europe", + "Why empires collapse", + "How writing was invented", + "What caused the Industrial Revolution", + "Why the Roman Empire fell", + "How money replaced barter", + "What sparked the Renaissance", + "How democracy began", + "Why the Silk Road mattered", + "What the Enlightenment changed", + "How plagues reshaped society", + "Why borders are where they are", + "How language families spread", + "What caused the Cold War", + "How cities first formed", + "Why revolutions happen", + "How navigation opened the world", + "What feudalism really was", + "How the calendar was set", + "Why some civilizations vanished", + "How earthquakes happen", + "Why volcanoes erupt", + "What causes weather", + "How mountains form", + "Why the ocean is salty", + "How rivers shape land", + "What drives ocean currents", + "Why deserts form where they do", + "How fossils are made", + "How clouds form rain", + "Why the climate is changing", + "How coral reefs grow", + "What plate tectonics explains", + "How caves form", + "Why leaves change color", + "How lightning forms", + "What soil is made of", + "How glaciers move", + "Why some places get monsoons", + "Why we procrastinate", + "How habits form", + "Why music moves us", + "How we learn languages", + "What makes things go viral", + "Why optical illusions fool us", + "How memory tricks work", + "Why we feel déjà vu", + "How persuasion works", + "What makes a melody catchy", + "Why crowds behave irrationally", + "How accents develop", + "Why we see faces everywhere", + "How the placebo effect works", + "Why deadlines boost focus", + "How color affects mood", + "Why stories stick with us", + "How bilingual brains switch", + "Why we forget names", + "How attention actually works" + ], + "spark": "Spark something", + "sparking": "Finding something…", + "or": "or", + "depthLabel": "Lesson depth", + "depth": { + "quick": "Quick", + "quickMeta": "~5 min", + "standard": "Standard", + "standardMeta": "~10 min", + "deep": "Deep", + "deepMeta": "~20 min" + }, + "modeLabel": "Format", + "mode": { + "practice": "Practice", + "read": "Just read" + }, + "continue": { + "heading": "Continue learning", + "lessonProgress": "Lesson {position} of {total}", + "resume": "Resume →" + }, + "dueReviews": "{count} due for review →" + }, + "auth": { + "login": { + "title": "Sign in", + "email": "Email", + "password": "Password", + "submit": "Sign in", + "submitting": "Signing in…", + "error": "Invalid email or password.", + "forgotPassword": "Forgot password?", + "noAccount": "No account?", + "register": "Register", + "resetSuccess": "Password updated. Sign in with your new password.", + "or": "or", + "continueWithGoogle": "Continue with Google", + "continueWithGitHub": "Continue with GitHub", + "continueWith": "Continue with {provider}" + }, + "register": { + "title": "Create account", + "name": "Name", + "email": "Email", + "password": "Password", + "confirmPassword": "Confirm password", + "passwordMismatch": "Passwords do not match.", + "submit": "Create account", + "submitting": "Creating account…", + "signInFailed": "Account created but sign-in failed. Try signing in manually.", + "hasAccount": "Already have an account?", + "signIn": "Sign in" + }, + "forgotPassword": { + "title": "Reset password", + "email": "Email", + "submit": "Send reset link", + "submitting": "Sending…", + "sentTitle": "Check your inbox", + "sentBody": "If an account exists for that email, we sent a reset link. It expires in 1 hour.", + "backToSignIn": "Back to sign in", + "error": "Something went wrong. Try again." + }, + "resetPassword": { + "title": "Set new password", + "newPassword": "New password", + "confirmPassword": "Confirm password", + "passwordMismatch": "Passwords do not match.", + "submit": "Set password", + "submitting": "Saving…", + "error": "Failed to reset password.", + "invalidLink": "Invalid reset link.", + "requestNew": "Request a new one" + }, + "acceptInvite": { + "title": "Accept your invitation", + "subtitle": "Set up your account to get started.", + "email": "Email", + "name": "Your name", + "password": "Password", + "confirmPassword": "Confirm password", + "passwordMismatch": "Passwords do not match.", + "submit": "Create account", + "submitting": "Creating…", + "error": "Could not accept this invite.", + "invalidLink": "This invite link is invalid, expired, or already used.", + "goToLogin": "Go to sign in" + }, + "verifyEmail": { + "title": "Verify your email", + "body": "We sent a verification link to your email address. Click it to activate your account.", + "subtext": "Didn't receive it? Check your spam folder." + }, + "error": { + "title": "Authentication error", + "default": "Something went wrong during sign in.", + "Configuration": "Server configuration error. Contact support.", + "AccessDenied": "Access denied.", + "Verification": "The verification link has expired or already been used.", + "backToSignIn": "Back to sign in" + } + }, + "settings": { + "title": "Settings", + "profile": { + "title": "Profile", + "name": "Display name", + "save": "Save", + "saving": "Saving…", + "saved": "Saved.", + "saveFailed": "Failed to save." + }, + "language": { + "title": "Language", + "label": "Interface language", + "description": "Choose the language used throughout the app.", + "en": "English", + "fr": "Français" + }, + "security": { + "title": "Security", + "currentPassword": "Current password", + "newPassword": "New password", + "confirmPassword": "Confirm new password", + "changePassword": "Change password", + "changing": "Changing…", + "changed": "Password changed.", + "failed": "Failed.", + "oauthNote": "Password change is not available for accounts using social sign-in.", + "linked": "Linked via: {providers}", + "passwordMismatch": "New passwords do not match." + }, + "appearance": { + "title": "Appearance", + "theme": { + "label": "Theme", + "system": "System", + "light": "Light", + "dark": "Dark" + } + }, + "danger": { + "title": "Danger zone", + "deleteAccount": "Delete account", + "deleteConfirm": "Delete my account", + "deleteWarning": "This will permanently delete your account and all your learning data. This cannot be undone.", + "deleting": "Deleting…" + }, + "learning": { + "title": "Learning", + "description": "Shape how lessons are written for you.", + "ageGroup": { + "label": "Who is learning?", + "child": "Child (8–12)", + "teen": "Teen (13–17)", + "adult": "Adult" + }, + "difficulty": { + "label": "Default difficulty", + "1": "Beginner", + "2": "Intermediate", + "3": "Advanced" + }, + "depth": { + "label": "Default lesson length", + "quick": "Quick (~5 min)", + "standard": "Standard (~10 min)", + "deep": "Deep (~20 min)" + }, + "hint": { + "label": "About you (optional)", + "placeholder": "e.g. I'm a nurse, I like analogies with cooking…", + "description": "A short note helps tailor explanations to your background." + }, + "save": "Save", + "saving": "Saving…", + "saved": "Saved.", + "saveFailed": "Failed to save." + }, + "notifications": { + "title": "Notifications", + "description": "Control how Curio contacts you.", + "emailDigest": "Send me a daily email when reviews are due" + } + }, + "onboarding": { + "title": "Welcome to Curio", + "subtitle": "Let's set up your learning profile. You can always change this later.", + "ageGroup": { + "label": "Who is learning?", + "child": "Child (8–12)", + "teen": "Teen (13–17)", + "adult": "Adult" + }, + "difficulty": { + "label": "How familiar are you with most topics?", + "1": "Beginner — build from first principles", + "2": "Intermediate — assume basic familiarity", + "3": "Advanced — go deep, explore edge cases" + }, + "depth": { + "label": "Preferred lesson length", + "quick": "Quick (~5 min)", + "standard": "Standard (~10 min)", + "deep": "Deep (~20 min)" + }, + "hint": { + "label": "Tell us a bit about yourself (optional)", + "placeholder": "e.g. I'm a nurse, I like analogies with cooking…", + "description": "Helps tailor explanations to your background." + }, + "submit": "Get started", + "submitting": "Saving…", + "skip": "Skip for now" + }, + "masteryMap": { + "title": "Mastery Map", + "loading": "Loading…", + "empty": "No mastery data yet.", + "emptyLink": "Start learning", + "due": "due", + "dueZone": { + "heading": "Due for review", + "startReview": "Start review →", + "none": "Nothing due right now." + }, + "overview": { + "mastered": "Mastered", + "learning": "Learning", + "needsWork": "Needs work", + "progress": "{mastered} of {total} concepts mastered" + }, + "status": { + "mastered": "Mastered", + "learning": "Learning", + "needsWork": "Needs work", + "notStarted": "Not started" + }, + "filter": { + "all": "All", + "due": "Due", + "needsWork": "Needs work" + }, + "remove": "Remove", + "removeBlueprint": "Remove {title} from mastery map" + }, + "profile": { + "title": "Your profile", + "masteryTitle": "Mastery overview", + "noMastery": "No mastery data yet. Start learning to see progress here." + }, + "grade": { + "verdict": { + "mastered": "Understood", + "partial": "Partial", + "misconception": "Misconception", + "uncertain": "Unclear" + }, + "calibration": { + "confidentFailed": "You rated yourself confident — worth pausing on what tripped you.", + "unsurePassed": "You rated yourself unsure, but your answer was solid. Trust your understanding." + }, + "referenceHeading": "Full answer", + "ariaFeedback": "Tutor feedback", + "ariaCalibration": "Calibration note", + "socratic": { + "ariaLabel": "Socratic follow-up", + "placeholder": "Go ahead…", + "ariaInput": "Follow-up answer", + "submit": "Reply", + "considering": "Considering…" + }, + "reexplain": { + "loading": "Preparing another explanation…", + "trigger": "Try another explanation →" + }, + "retry": "Try again →", + "report": { + "toggle": "Flag an issue", + "ariaLabel": "Report an issue", + "thanks": "Thanks — we'll look into it.", + "reasonGradeWrong": "Grade seems wrong", + "reasonContentError": "Content error", + "reasonOther": "Other", + "notesPlaceholder": "Optional details…", + "ariaReason": "Reason", + "ariaDetails": "Details", + "submit": "Send", + "cancel": "Cancel" + } + } +} \ No newline at end of file diff --git a/messages/fr.json b/messages/fr.json new file mode 100644 index 0000000..7f58518 --- /dev/null +++ b/messages/fr.json @@ -0,0 +1,586 @@ +{ + "lesson": { + "ariaLesson": "Leçon", + "ariaProgress": "Progression de la leçon", + "reading": "Lecture", + "segments": "{count, plural, one {# segment} other {# segments}}", + "segmentLocked": "Complétez le point de contrôle précédent pour continuer.", + "checkpoint": { + "predict": "Prédire", + "explain": "Expliquer", + "solve": "Résoudre", + "placeholder": "Écrivez votre réponse ici…", + "checking": "Analyse en cours…", + "submitted": "Envoyé", + "confidenceLegend": "Avant d'envoyer — à quel point êtes-vous sûr(e) ?", + "notSure": "Pas sûr(e)", + "thinkSo": "Je pense", + "confident": "Confiant(e)", + "notSureAria": "Je ne suis pas sûr(e) de ma réponse", + "thinkSoAria": "Je pense que ma réponse est correcte", + "confidentAria": "Je suis confiant(e) dans ma réponse" + }, + "print": "Exporter PDF", + "considering": "En cours d'évaluation…", + "empty": "Cette leçon n'a pas encore de contenu. Revenez dans un instant — le contenu est en cours de préparation.", + "outline": { + "intro": "Ce que vous allez explorer", + "preparing": "Génération de votre leçon en cours…", + "streaming": "Rédaction de votre leçon…", + "streamingLabel": "Contenu de la leçon en chargement" + }, + "tangent": { + "placeholder": "Quelque chose vous intrigue ?", + "ariaInput": "Question de curiosité", + "ariaAsk": "Envoyer", + "considering": "Je cherche…" + }, + "journeyProgress": "Leçon {current} sur {total}", + "complete": { + "ariaLabel": "Leçon terminée", + "heading": "Leçon terminée.", + "saved": "Progression sauvegardée.", + "saving": "Sauvegarde…", + "markComplete": "Marquer comme terminé", + "nextLesson": "Leçon suivante →", + "deepen": "Approfondir →", + "journeyDone": "Parcours terminé.", + "journeyDoneNote": "Vous avez terminé toutes les leçons de ce parcours." + } + }, + "review": { + "title": "Révision quotidienne", + "aria": "Session de révision quotidienne", + "loading": "Chargement de votre révision…", + "empty": "Rien à réviser pour l'instant. Revenez demain.", + "progress": "{current} sur {total}", + "considering": "En cours d'analyse…", + "next": "Suivant →", + "finish": "Terminer", + "complete": { + "heading": "Session terminée", + "reviewed": "{count, plural, one {# concept révisé} other {# concepts révisés}}", + "netDelta": "Maîtrise nette : {delta}", + "nothingDue": "Vous êtes à jour.", + "backHome": "Retour à l'apprentissage" + }, + "delta": { + "up": "↑ {pct}%", + "down": "↓ {pct}%", + "same": "inchangé" + }, + "emptyUntil": "Tout est à jour. Votre prochaine révision est le {date}.", + "emptyNoHistory": "Pas encore de révisions. Complétez une leçon pour commencer." + }, + "nav": { + "learn": "Apprendre", + "review": "Révision", + "mastery": "Maîtrise", + "map": "Carte", + "signIn": "Se connecter", + "signOut": "Se déconnecter", + "profile": "Profil", + "admin": "Admin" + }, + "home": { + "eyebrow": "Maîtrise guidée par la curiosité", + "title": "Qu'est-ce que vous voulez comprendre ?", + "lede": "Dites-nous ce qui vous intrigue.", + "fieldLabel": "Votre intention d'apprentissage", + "placeholder": "ex. comment fonctionnent les vaccins", + "examplesLabel": "Ou essayez l'un de ceux-ci", + "submit": "Commencer", + "submitting": "Recherche de votre leçon…", + "error": "Une erreur s'est produite. Réessayez.", + "examples": [ + "Pourquoi la glace est glissante", + "Comment un gyroscope reste droit", + "Pourquoi le ciel est bleu", + "Ce qu'est vraiment la température", + "Comment fonctionne un casque à réduction de bruit", + "Pourquoi la vitesse de la lumière est une limite", + "Comment fonctionnent les lasers", + "Pourquoi les aimants attirent", + "Ce qui cause la friction", + "Comment un micro-ondes chauffe les aliments", + "Pourquoi le verre est transparent", + "Comment fonctionnent les supraconducteurs", + "Ce qu'est l'intrication quantique", + "Pourquoi les miroirs inversent la gauche et la droite", + "Comment le son se propage", + "Ce qui crée un arc-en-ciel", + "Pourquoi l'air chaud monte", + "Comment fonctionne un réacteur nucléaire", + "Ce que pourrait être la matière noire", + "Pourquoi le temps ralentit près de la lumière", + "Comment se forment les trous noirs", + "Pourquoi la Lune cause les marées", + "Ce qu'est une étoile à neutrons", + "Comment les étoiles fabriquent les éléments", + "Pourquoi Mars est rouge", + "Comment les fusées atteignent l'orbite", + "Ce qu'était le Big Bang", + "Pourquoi Pluton n'est pas une planète", + "Comment le GPS utilise la relativité", + "Ce qui cause les éclipses solaires", + "Comment les télescopes voient le passé", + "Pourquoi les galaxies sont en spirale", + "Ce que mesure une année-lumière", + "Comment le Soleil produit son énergie", + "Pourquoi l'espace est froid mais le soleil brûle", + "Ce qui cause les saisons", + "Comment les comètes ont une queue", + "Pourquoi l'univers est en expansion", + "Ce que sont les exoplanètes", + "Comment se forment les aurores", + "Comment fonctionnent les vaccins", + "Pourquoi les oignons font pleurer", + "Comment la mémoire se forme", + "Pourquoi nous rêvons", + "Comment les muscles se développent", + "Ce qui cause le vieillissement", + "Comment les antibiotiques tuent les bactéries", + "Pourquoi nous avons de la fièvre", + "Comment le système immunitaire apprend", + "Ce que fait vraiment l'ADN", + "Comment les nerfs transmettent des signaux", + "Pourquoi nous bâillons", + "Comment le cœur garde son rythme", + "Ce que font les bactéries intestinales", + "Comment les blessures guérissent", + "Pourquoi nous avons besoin de dormir", + "Comment les yeux perçoivent la couleur", + "Ce qui cause les allergies", + "Comment les cellules se divisent", + "Pourquoi l'exercice développe l'endurance", + "Pourquoi le savon nettoie", + "Comment les batteries stockent l'énergie", + "Ce qui rend une substance acide", + "Comment on fabrique le verre", + "Pourquoi les métaux conduisent l'électricité", + "Comment les feux d'artifice ont des couleurs", + "Ce qu'est vraiment la rouille", + "Comment les catalyseurs accélèrent les réactions", + "Pourquoi l'huile et l'eau ne se mélangent pas", + "Comment on fabrique le plastique", + "Ce que mesure le pH", + "Comment fonctionne la fermentation", + "Pourquoi le diamant est dur", + "Comment se forment les bulles de savon", + "Ce qui rend les piments forts", + "Comment fonctionne la glace sèche", + "Pourquoi le pain lève", + "Comment le parfum se diffuse", + "Ce que sont les alliages", + "Comment fonctionne la datation au carbone", + "Le contrôle de congestion TCP", + "Comment le chiffrement protège les secrets", + "Ce que fait un index de base de données", + "Comment les réseaux de neurones apprennent", + "Pourquoi les mots de passe sont hachés", + "Comment Internet achemine les données", + "Ce que fait un compilateur", + "Comment le GPS trouve votre position", + "Pourquoi les ordinateurs utilisent le binaire", + "Comment un processeur exécute du code", + "Ce que résout vraiment le cache", + "Comment le Wi-Fi transporte les données", + "Ce qu'est vraiment la blockchain", + "Comment les moteurs de recherche classent les pages", + "Pourquoi la RAM est plus rapide que le disque", + "Comment fonctionne la compression d'images", + "Ce qu'est une API", + "Comment fonctionne la cryptographie à clé publique", + "Pourquoi les logiciels ont des bugs", + "Comment fonctionne la traduction automatique", + "Pourquoi les nombres premiers comptent", + "Ce que mesure le calcul différentiel", + "Comment fonctionnent les probabilités", + "Pourquoi pi est partout", + "Ce que signifie vraiment l'infini", + "Ce que révèle une dérivée", + "Pourquoi le zéro fut révolutionnaire", + "Comment les statistiques peuvent tromper", + "Ce que signifie la croissance exponentielle", + "Pourquoi certains problèmes sont insolubles", + "Comment les intérêts composés croissent", + "Ce qu'est un logarithme", + "Pourquoi la courbe en cloche apparaît", + "Comment on crée les fractales", + "Ce que fait le théorème de Bayes", + "Pourquoi on ne peut pas diviser par zéro", + "Comment le GPS triangule une position", + "Ce que représente une matrice", + "Pourquoi le hasard est difficile", + "Comment les banques fixent les taux", + "Ce qui cause l'inflation", + "Comment fonctionnent les marchés boursiers", + "Pourquoi l'argent a de la valeur", + "Ce qu'est une récession", + "Comment les impôts influencent les comportements", + "Pourquoi les prix montent et baissent", + "Ce que signifie l'offre et la demande", + "Comment l'assurance répartit le risque", + "Pourquoi le commerce profite aux deux camps", + "Ce que mesure le PIB", + "Comment fonctionnent les scores de crédit", + "Pourquoi les bulles se forment et éclatent", + "Comment évoluent les taux de change", + "Pourquoi les monopoles sont risqués", + "Ce qu'est une obligation", + "Comment fonctionne le capital-risque", + "Pourquoi la diversification compte", + "Ce qu'explique la théorie des jeux", + "Comment l'imprimerie a changé l'Europe", + "Pourquoi les empires s'effondrent", + "Comment l'écriture fut inventée", + "Ce qui a causé la révolution industrielle", + "Pourquoi l'Empire romain est tombé", + "Comment la monnaie a remplacé le troc", + "Ce qui a déclenché la Renaissance", + "Comment la démocratie a commencé", + "Pourquoi la Route de la soie comptait", + "Ce qu'ont changé les Lumières", + "Comment les épidémies ont remodelé la société", + "Pourquoi les frontières sont là où elles sont", + "Comment les familles de langues se sont répandues", + "Ce qui a causé la guerre froide", + "Comment les premières villes sont nées", + "Pourquoi les révolutions arrivent", + "Comment la navigation a ouvert le monde", + "Ce qu'était vraiment la féodalité", + "Comment le calendrier fut fixé", + "Pourquoi certaines civilisations ont disparu", + "Comment se produisent les tremblements de terre", + "Pourquoi les volcans entrent en éruption", + "Ce qui cause la météo", + "Comment se forment les montagnes", + "Pourquoi l'océan est salé", + "Comment les rivières façonnent le paysage", + "Ce qui anime les courants océaniques", + "Pourquoi les déserts se forment où ils sont", + "Comment se forment les fossiles", + "Comment les nuages forment la pluie", + "Pourquoi le climat change", + "Comment grandissent les récifs coralliens", + "Ce qu'explique la tectonique des plaques", + "Comment se forment les grottes", + "Pourquoi les feuilles changent de couleur", + "Comment se forme la foudre", + "De quoi le sol est fait", + "Comment les glaciers se déplacent", + "Pourquoi certaines régions ont des moussons", + "Pourquoi nous procrastinons", + "Comment se forment les habitudes", + "Pourquoi la musique nous émeut", + "Comment nous apprenons les langues", + "Ce qui rend les choses virales", + "Pourquoi les illusions d'optique nous trompent", + "Comment marchent les astuces de mémoire", + "Pourquoi nous ressentons le déjà-vu", + "Comment fonctionne la persuasion", + "Ce qui rend une mélodie entêtante", + "Pourquoi les foules agissent irrationnellement", + "Comment se développent les accents", + "Pourquoi nous voyons des visages partout", + "Comment fonctionne l'effet placebo", + "Pourquoi les délais stimulent la concentration", + "Comment la couleur influence l'humeur", + "Pourquoi les histoires nous marquent", + "Comment le cerveau bilingue change de langue", + "Pourquoi nous oublions les noms", + "Comment fonctionne vraiment l'attention" + ], + "spark": "Surprenez-moi", + "sparking": "Recherche en cours…", + "or": "ou", + "depthLabel": "Profondeur de la leçon", + "depth": { + "quick": "Express", + "quickMeta": "~5 min", + "standard": "Standard", + "standardMeta": "~10 min", + "deep": "Approfondi", + "deepMeta": "~20 min" + }, + "modeLabel": "Format", + "mode": { + "practice": "Exercices", + "read": "Lecture seule" + }, + "continue": { + "heading": "Reprendre", + "lessonProgress": "Leçon {position} sur {total}", + "resume": "Reprendre →" + }, + "dueReviews": "{count} à réviser →" + }, + "auth": { + "login": { + "title": "Connexion", + "email": "Email", + "password": "Mot de passe", + "submit": "Se connecter", + "submitting": "Connexion…", + "error": "Email ou mot de passe incorrect.", + "forgotPassword": "Mot de passe oublié ?", + "noAccount": "Pas de compte ?", + "register": "S'inscrire", + "resetSuccess": "Mot de passe mis à jour. Connectez-vous avec votre nouveau mot de passe.", + "or": "ou", + "continueWithGoogle": "Continuer avec Google", + "continueWithGitHub": "Continuer avec GitHub", + "continueWith": "Continuer avec {provider}" + }, + "register": { + "title": "Créer un compte", + "name": "Nom", + "email": "Email", + "password": "Mot de passe", + "confirmPassword": "Confirmer le mot de passe", + "passwordMismatch": "Les mots de passe ne correspondent pas.", + "submit": "Créer un compte", + "submitting": "Création en cours…", + "signInFailed": "Compte créé mais la connexion a échoué. Essayez de vous connecter manuellement.", + "hasAccount": "Déjà un compte ?", + "signIn": "Se connecter" + }, + "forgotPassword": { + "title": "Réinitialiser le mot de passe", + "email": "Email", + "submit": "Envoyer le lien", + "submitting": "Envoi…", + "sentTitle": "Vérifiez votre boîte mail", + "sentBody": "Si un compte existe pour cet email, nous avons envoyé un lien de réinitialisation. Il expire dans 1 heure.", + "backToSignIn": "Retour à la connexion", + "error": "Une erreur s'est produite. Réessayez." + }, + "resetPassword": { + "title": "Définir un nouveau mot de passe", + "newPassword": "Nouveau mot de passe", + "confirmPassword": "Confirmer le mot de passe", + "passwordMismatch": "Les mots de passe ne correspondent pas.", + "submit": "Enregistrer", + "submitting": "Enregistrement…", + "error": "Échec de la réinitialisation du mot de passe.", + "invalidLink": "Lien de réinitialisation invalide.", + "requestNew": "Demander un nouveau lien" + }, + "acceptInvite": { + "title": "Accepter votre invitation", + "subtitle": "Configurez votre compte pour commencer.", + "email": "E-mail", + "name": "Votre nom", + "password": "Mot de passe", + "confirmPassword": "Confirmer le mot de passe", + "passwordMismatch": "Les mots de passe ne correspondent pas.", + "submit": "Créer le compte", + "submitting": "Création…", + "error": "Impossible d’accepter cette invitation.", + "invalidLink": "Ce lien d’invitation est invalide, expiré ou déjà utilisé.", + "goToLogin": "Aller à la connexion" + }, + "verifyEmail": { + "title": "Vérifiez votre email", + "body": "Nous avons envoyé un lien de vérification à votre adresse email. Cliquez dessus pour activer votre compte.", + "subtext": "Vous ne l'avez pas reçu ? Vérifiez vos spams." + }, + "error": { + "title": "Erreur d'authentification", + "default": "Une erreur s'est produite lors de la connexion.", + "Configuration": "Erreur de configuration du serveur. Contactez le support.", + "AccessDenied": "Accès refusé.", + "Verification": "Le lien de vérification a expiré ou a déjà été utilisé.", + "backToSignIn": "Retour à la connexion" + } + }, + "settings": { + "title": "Paramètres", + "profile": { + "title": "Profil", + "name": "Nom affiché", + "save": "Enregistrer", + "saving": "Enregistrement…", + "saved": "Enregistré.", + "saveFailed": "Échec de l'enregistrement." + }, + "language": { + "title": "Langue", + "label": "Langue de l'interface", + "description": "Choisissez la langue utilisée dans toute l'application.", + "en": "English", + "fr": "Français" + }, + "security": { + "title": "Sécurité", + "currentPassword": "Mot de passe actuel", + "newPassword": "Nouveau mot de passe", + "confirmPassword": "Confirmer le nouveau mot de passe", + "changePassword": "Changer le mot de passe", + "changing": "Modification…", + "changed": "Mot de passe modifié.", + "failed": "Échec.", + "oauthNote": "Le changement de mot de passe n'est pas disponible pour les comptes utilisant la connexion sociale.", + "linked": "Connexion via : {providers}", + "passwordMismatch": "Les nouveaux mots de passe ne correspondent pas." + }, + "appearance": { + "title": "Apparence", + "theme": { + "label": "Thème", + "system": "Système", + "light": "Clair", + "dark": "Sombre" + } + }, + "danger": { + "title": "Zone dangereuse", + "deleteAccount": "Supprimer le compte", + "deleteConfirm": "Supprimer mon compte", + "deleteWarning": "Cette action supprimera définitivement votre compte et toutes vos données d'apprentissage. Elle est irréversible.", + "deleting": "Suppression…" + }, + "learning": { + "title": "Apprentissage", + "description": "Personnalisez la façon dont les leçons sont rédigées pour vous.", + "ageGroup": { + "label": "Pour qui est-ce ?", + "child": "Enfant (8–12 ans)", + "teen": "Adolescent (13–17 ans)", + "adult": "Adulte" + }, + "difficulty": { + "label": "Niveau de difficulté par défaut", + "1": "Débutant", + "2": "Intermédiaire", + "3": "Avancé" + }, + "depth": { + "label": "Durée de leçon par défaut", + "quick": "Courte (~5 min)", + "standard": "Standard (~10 min)", + "deep": "Approfondie (~20 min)" + }, + "hint": { + "label": "À propos de vous (optionnel)", + "placeholder": "p. ex. Je suis infirmière, j'aime les analogies avec la cuisine…", + "description": "Une courte note aide à adapter les explications à votre profil." + }, + "save": "Enregistrer", + "saving": "Enregistrement…", + "saved": "Enregistré.", + "saveFailed": "Échec de l'enregistrement." + }, + "notifications": { + "title": "Notifications", + "description": "Gérez comment Curio vous contacte.", + "emailDigest": "M'envoyer un email quotidien quand des révisions sont en attente" + } + }, + "onboarding": { + "title": "Bienvenue sur Curio", + "subtitle": "Configurez votre profil d'apprentissage. Vous pourrez le modifier à tout moment.", + "ageGroup": { + "label": "Pour qui est-ce ?", + "child": "Enfant (8–12 ans)", + "teen": "Adolescent (13–17 ans)", + "adult": "Adulte" + }, + "difficulty": { + "label": "Quel est votre niveau habituel ?", + "1": "Débutant — partir de zéro", + "2": "Intermédiaire — partir des bases", + "3": "Avancé — aller en profondeur" + }, + "depth": { + "label": "Durée de leçon préférée", + "quick": "Courte (~5 min)", + "standard": "Standard (~10 min)", + "deep": "Approfondie (~20 min)" + }, + "hint": { + "label": "Parlez-nous de vous (optionnel)", + "placeholder": "p. ex. Je suis infirmière, j'aime les analogies avec la cuisine…", + "description": "Aide à adapter les explications à votre profil." + }, + "submit": "Commencer", + "submitting": "Enregistrement…", + "skip": "Passer pour l'instant" + }, + "masteryMap": { + "title": "Carte de maîtrise", + "loading": "Chargement…", + "empty": "Aucune donnée de maîtrise pour l'instant.", + "emptyLink": "Commencer à apprendre", + "due": "à revoir", + "dueZone": { + "heading": "À réviser", + "startReview": "Commencer la révision →", + "none": "Rien à revoir pour l'instant." + }, + "overview": { + "mastered": "Maîtrisés", + "learning": "En cours", + "needsWork": "À revoir", + "progress": "{mastered} concept{mastered, plural, one {} other {s}} maîtrisé{mastered, plural, one {} other {s}} sur {total}" + }, + "status": { + "mastered": "Maîtrisé", + "learning": "En cours", + "needsWork": "À revoir", + "notStarted": "Non commencé" + }, + "filter": { + "all": "Tout", + "due": "À réviser", + "needsWork": "À consolider" + }, + "remove": "Retirer", + "removeBlueprint": "Retirer {title} de la carte de maîtrise" + }, + "profile": { + "title": "Votre profil", + "masteryTitle": "Aperçu de la maîtrise", + "noMastery": "Aucune donnée de maîtrise pour l'instant. Commencez à apprendre pour voir votre progression ici." + }, + "grade": { + "verdict": { + "mastered": "Compris", + "partial": "Partiel", + "misconception": "Erreur de compréhension", + "uncertain": "Incertain" + }, + "calibration": { + "confidentFailed": "Vous vous êtes senti confiant — cela vaut la peine de réfléchir à ce qui vous a fait trébucher.", + "unsurePassed": "Vous vous êtes dit incertain, mais votre réponse était solide. Faites confiance à votre compréhension." + }, + "referenceHeading": "Réponse complète", + "ariaFeedback": "Retour du tuteur", + "ariaCalibration": "Note de calibration", + "socratic": { + "ariaLabel": "Question socratique", + "placeholder": "Allez-y…", + "ariaInput": "Réponse de suivi", + "submit": "Répondre", + "considering": "En cours d'évaluation…" + }, + "reexplain": { + "loading": "Préparation d'une autre explication…", + "trigger": "Essayer une autre explication →" + }, + "retry": "Réessayer →", + "report": { + "toggle": "Signaler un problème", + "ariaLabel": "Signaler un problème", + "thanks": "Merci — nous allons examiner ça.", + "reasonGradeWrong": "La note semble incorrecte", + "reasonContentError": "Erreur de contenu", + "reasonOther": "Autre", + "notesPlaceholder": "Détails optionnels…", + "ariaReason": "Raison", + "ariaDetails": "Détails", + "submit": "Envoyer", + "cancel": "Annuler" + } + } +} \ No newline at end of file diff --git a/next.config.ts b/next.config.ts index b22af96..b4944bc 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,5 +1,10 @@ import type { NextConfig } from 'next'; +import createNextIntlPlugin from 'next-intl/plugin'; -const nextConfig: NextConfig = {}; +const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts'); -export default nextConfig; +const nextConfig: NextConfig = { + serverExternalPackages: ['@react-pdf/renderer'], +}; + +export default withNextIntl(nextConfig); diff --git a/package.json b/package.json index efa66e2..214d07b 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "test:golden": "tsx --env-file=.env.local tests/golden/run-grading-eval.ts", "test:golden:misconceptions": "tsx --env-file=.env.local tests/golden/run-misconception-eval.ts", "test:golden:content": "tsx --env-file=.env.local tests/golden/run-content-eval.ts", - "workers": "tsx --env-file=.env.local jobs/index.ts" + "workers": "tsx --env-file=.env.local jobs/bootstrap.ts" }, "dependencies": { "@ai-sdk/amazon-bedrock": "^4.0.119", @@ -33,6 +33,7 @@ "@ai-sdk/provider": "^1.0.0", "@ai-sdk/xai": "^3.0.96", "@auth/drizzle-adapter": "^1.11.2", + "@react-pdf/renderer": "^4.5.1", "@upstash/ratelimit": "^2.0.8", "@upstash/redis": "^1.38.0", "ai": "^4.3.0", @@ -43,18 +44,26 @@ "langfuse": "^3.0.0", "next": "^15.3.0", "next-auth": "5.0.0-beta.31", + "next-intl": "^4.13.0", "nodemailer": "^9.0.1", "ollama-ai-provider": "^1.2.0", "pg": "^8.13.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-markdown": "^10.1.0", + "remark": "^15.0.1", + "remark-breaks": "^4.0.0", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", "server-only": "^0.0.1", + "unified": "^11.0.5", "zod": "^3.24.0" }, "devDependencies": { "@playwright/test": "^1.49.0", "@tailwindcss/postcss": "^4.1.0", "@types/bcryptjs": "^3.0.0", + "@types/mdast": "^4.0.4", "@types/node": "^22.0.0", "@types/nodemailer": "^8.0.1", "@types/pg": "^8.11.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dee405c..5dac038 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@auth/drizzle-adapter': specifier: ^1.11.2 version: 1.11.2(nodemailer@9.0.1) + '@react-pdf/renderer': + specifier: ^4.5.1 + version: 4.5.1(react@19.2.7) '@upstash/ratelimit': specifier: ^2.0.8 version: 2.0.8(@upstash/redis@1.38.0) @@ -71,6 +74,9 @@ importers: next-auth: specifier: 5.0.0-beta.31 version: 5.0.0-beta.31(next@15.5.19(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(nodemailer@9.0.1)(react@19.2.7) + next-intl: + specifier: ^4.13.0 + version: 4.13.0(next@15.5.19(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(typescript@5.9.3) nodemailer: specifier: ^9.0.1 version: 9.0.1 @@ -86,9 +92,27 @@ importers: react-dom: specifier: ^19.0.0 version: 19.2.7(react@19.2.7) + react-markdown: + specifier: ^10.1.0 + version: 10.1.0(@types/react@19.2.17)(react@19.2.7) + remark: + specifier: ^15.0.1 + version: 15.0.1 + remark-breaks: + specifier: ^4.0.0 + version: 4.0.0 + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1 + remark-parse: + specifier: ^11.0.0 + version: 11.0.0 server-only: specifier: ^0.0.1 version: 0.0.1 + unified: + specifier: ^11.0.5 + version: 11.0.5 zod: specifier: ^3.24.0 version: 3.25.76 @@ -102,6 +126,9 @@ importers: '@types/bcryptjs': specifier: ^3.0.0 version: 3.0.0 + '@types/mdast': + specifier: ^4.0.4 + version: 4.0.4 '@types/node': specifier: ^22.0.0 version: 22.19.21 @@ -149,7 +176,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.1.0 - version: 3.2.6(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4) + version: 3.2.6(@types/debug@4.1.13)(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4) packages: @@ -370,6 +397,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -1025,6 +1056,18 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@formatjs/fast-memoize@3.1.6': + resolution: {integrity: sha512-H5aexk1Le7T9TPmscacZ+1pR6CTa2n1wq+HDVGXhH8TzUlQQpeXzZs91dRtmFHrbeNbjPFPfQujUqm7MHgVoXQ==} + + '@formatjs/icu-messageformat-parser@3.5.11': + resolution: {integrity: sha512-NVsuNsc2dUVG9+4HBJ/srScxtA/18LqGgwtop/tuN/OIBjVl6QA+0KhfZQddDD9sEh2LeVjLFPGVU3ixa3blcA==} + + '@formatjs/icu-skeleton-parser@2.1.10': + resolution: {integrity: sha512-XuSva+8ZGawk8VnD5VD6UeH8KarQ/Z022zgjHDoHmlNiAewstXuuzXc0Hk5pGFSdG+nNw5bfJKXqj1ZXHn9yUA==} + + '@formatjs/intl-localematcher@0.8.10': + resolution: {integrity: sha512-P/IC3qws3jH+1fEs+o0RIFgXKRaQlFehjS5W0FPAqdo6hgzawLl+eD0q0JjheQ3XtoOe5n8WSYfX06KQZI/QJA==} + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -1314,6 +1357,14 @@ packages: cpu: [x64] os: [win32] + '@noble/ciphers@1.3.0': + resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1337,6 +1388,94 @@ packages: '@panva/hkdf@1.2.1': resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} + '@parcel/watcher-android-arm64@2.5.6': + resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.5.6': + resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.5.6': + resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.5.6': + resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.5.6': + resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm-musl@2.5.6': + resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-arm64-glibc@2.5.6': + resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm64-musl@2.5.6': + resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-x64-glibc@2.5.6': + resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-x64-musl@2.5.6': + resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@parcel/watcher-win32-arm64@2.5.6': + resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-ia32@2.5.6': + resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + + '@parcel/watcher-win32-x64@2.5.6': + resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.5.6': + resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} + engines: {node: '>= 10.0.0'} + '@petamoriken/float16@3.9.3': resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==} @@ -1345,6 +1484,49 @@ packages: engines: {node: '>=18'} hasBin: true + '@react-pdf/fns@3.1.3': + resolution: {integrity: sha512-0I7pApDr1/RLAKbizuLy/IHTEa93LSPy/bEwYniboC3Xqnp6Od8xFJKbKEzGw2wh/5zKFFwl00g4t9RwgIMc3w==} + + '@react-pdf/font@4.0.8': + resolution: {integrity: sha512-deNd+emtZAJho1IlzKL9bRoLAGv/6oXOIKO2oZfs4RuXUrK1onLHbJO7e2YoVLPFP/sQxisRTnzdJFtd35iKwA==} + + '@react-pdf/image@3.1.0': + resolution: {integrity: sha512-ks7Ry8v711r8NvKWSELehj0BXBNPRihSnWsM09nDD8Ur175zbWBCK217LLwQMKDNYDVpkZaipdoJPom1LGaE9g==} + + '@react-pdf/layout@4.6.1': + resolution: {integrity: sha512-gN6PmWoEffvlIkifLfEhMsVucRywVMyH3rnxdyOVOhGy0nWJKKGpHyPc4plbDdpP6EfZ0r8prHXujDSkIG2nSA==} + + '@react-pdf/pdfkit@5.1.1': + resolution: {integrity: sha512-wNcdSsNlNYyGHGAgIdt453egBF7fiF9UxpRlklUfVvu8OWCrUppG9xiUrPLVoKiqWet5tMi0w6LmuFUJuYqjEg==} + + '@react-pdf/primitives@4.3.0': + resolution: {integrity: sha512-nYXoZ36pvwNzbc54+DbL8RCn15jU7woJ9D/svnh5tpUXekJ+CbI4mZLo6boSv24CvJgychOu6h7gxX03B4ps0A==} + + '@react-pdf/reconciler@2.0.0': + resolution: {integrity: sha512-7zaPRujpbHSmCpIrZ+b9HSTJHthcVZzX0Wx7RzvQGsGBUbHP4p6s5itXrAIOuQuPvDepoHGNOvf6xUuMVvdoyw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@react-pdf/render@4.5.1': + resolution: {integrity: sha512-IW/N4HWJWtioBXCf7n02IR24VJJ8gbdS3jGypf+vW/rSErEx3/URRzh9UK6Ma8Fpog9+T/W6GE2NHJ5AAKHhVA==} + + '@react-pdf/renderer@4.5.1': + resolution: {integrity: sha512-5r1VQrE6FRLXX5wWUxwZzM24E2BJMo6g8AQWuS8WyPs9ugu5yMnb2g8/RpPYka/Z6J+RUEWc32wty2NoUJF42Q==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@react-pdf/stylesheet@6.2.1': + resolution: {integrity: sha512-2+UEk+7e+z8baaWi2l5kPLWmwtJeOI+T5wW9GGeN3iDH7vd3kbTqOpN1yt9mmfNVZFxQsnDHpznFb5v5UF983A==} + + '@react-pdf/svg@1.1.0': + resolution: {integrity: sha512-cTIHXiz9x1HrbfqzfxfZP3FRdDwUXG77QWF6Fb5MP/lV3ONxR+g0Z3hwtBatCS9HeGBQCpxX/Lzb8wHE+co1PA==} + + '@react-pdf/textkit@6.3.0': + resolution: {integrity: sha512-v6+V8nAcVwm7s2s1jIG2MD3Iw//x/k+XrH1foWOELBE4b32pyDgKyPXN/6KJE0dnX7+fVy27uctLNCLNMvzKzQ==} + + '@react-pdf/types@2.11.1': + resolution: {integrity: sha512-i9xQgfaDU9QoeNnbp6rltXCWg1huEh195rpOuN8cE4BZ2FuLdQrsIcb2dhFF9aOxXf+XBA6LOSpIW051MDD/bw==} + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -1492,6 +1674,9 @@ packages: '@rushstack/eslint-patch@1.16.1': resolution: {integrity: sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==} + '@schummar/icu-type-parser@1.21.5': + resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==} + '@smithy/core@3.25.1': resolution: {integrity: sha512-zpDbpXBCBsxfLtG2GEUyfgvHvSFrw5CwDZSNzL0v52gx/c3oPlPbm+7W7num8xs6vyiUBn+bvYPHcQDOXZynCQ==} engines: {node: '>=18.0.0'} @@ -1523,9 +1708,102 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@swc/core-darwin-arm64@1.15.41': + resolution: {integrity: sha512-kREh6J5paQFvP3i7f/4FbqRNOJREutVFVOkder4GVyCBQ39YmER55cW/y1NNjwrchzFqgYswFn0mMDCqbqKzrw==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.15.41': + resolution: {integrity: sha512-N8B56ESFazZAWZyIkecADSPCwlLEinW7QLMEeotCpv4J7VXwfH+OLkmRL8o96UZ+1355fwHxDTS6/wK7yucvkA==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.15.41': + resolution: {integrity: sha512-6XrId2fyle0mS5xxON8rU84mPd2Cq1kDJRj+4BnQKTd7u+2kSA6Ww+JkOP0iTNqOqt9OXhPOEAjBHAuonWcdCg==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.15.41': + resolution: {integrity: sha512-ynLIarxlkVnqHn1D0fKOVht6mNU5ks6lrH+MY3kkS+XFaGGgDxFZVjWKJlkYTKm3RCvBTfA8Ng5fLufXheMRKQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-arm64-musl@1.15.41': + resolution: {integrity: sha512-dXu/5vd4gh8symyhRF+4G7gOPkjmb4pONhh7sl+6GSiW0LOKZlfu5kXmyFbTz9smOT7jgr002qY9b1nujjXt2A==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@swc/core-linux-ppc64-gnu@1.15.41': + resolution: {integrity: sha512-XGO6zVPXoPE0gf/XnI4jBbafNT13AYgoh6ns0JCSdOetI/kqVf0vhpz7NuNgAzZrMVCsmieqjPoTwViDgh4mOQ==} + engines: {node: '>=10'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-s390x-gnu@1.15.41': + resolution: {integrity: sha512-0WUglRwyZtW+iMi7J3iFdrCxreZZIKf4egTwEQfIYRsqFax69A0OrFj+NIoFSE03xBT/IFRrg+S8K6f9Ky+4hA==} + engines: {node: '>=10'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-gnu@1.15.41': + resolution: {integrity: sha512-VxkuQK59c0tHm6uJZCUrS3cyA2JhGGfdU6e41SZz0x/JS+4Sm7C1mIc97In14vkZJopEt7yXA2TouCqZDSygEA==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-musl@1.15.41': + resolution: {integrity: sha512-/0qXIu1ZxggLuovLb22vFfKHq2AA4n6Whw5UwmVCHk4pkw7KWnPIQpMCEqUMPsNkFJig7PPp/TSYFu8ZEb2rtQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@swc/core-win32-arm64-msvc@1.15.41': + resolution: {integrity: sha512-Y481sMNZM6rECh9VO4+y26N1lWEDAyxnBZskUf37fl90uHE946VHfmiVQWT0uMFOhyJJFovGTRuF4W82dwewUg==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.15.41': + resolution: {integrity: sha512-BAchBD5qeUzy3hiPSLJtaaoSm4blCLyYffOF1bGE4ETcV+OisqjUAwDQMJj++4bTpvMCDzwC+Bj3PmQyBCtscw==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.15.41': + resolution: {integrity: sha512-WOkA+fJ/ViVBQDsSV9JC52NACTe5PhlurA6viASDZGb7HR3KS01ZG7RZ+Bg6SVQFIoq3gSbTsskQVe6EbHFAYw==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.15.41': + resolution: {integrity: sha512-03nQq/082QRJJiOvp3FGbgxTGyyxMxohPTjhk/W9bD2J0tk4ukITI7goOhOO2WbaHn/lsPmo/zf8+DIXhwpgYQ==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@swc/types@0.1.27': + resolution: {integrity: sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==} + '@tailwindcss/node@4.3.1': resolution: {integrity: sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==} @@ -1640,21 +1918,36 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} '@types/diff-match-patch@1.0.36': resolution: {integrity: sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==} + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@22.19.21': resolution: {integrity: sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==} @@ -1672,6 +1965,12 @@ packages: '@types/react@19.2.17': resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@typescript-eslint/eslint-plugin@8.61.1': resolution: {integrity: sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1731,6 +2030,9 @@ packages: resolution: {integrity: sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ungap/structured-clone@1.3.1': + resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} + '@unrs/resolver-binding-android-arm-eabi@1.12.2': resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==} cpu: [arm] @@ -1898,6 +2200,9 @@ packages: '@vitest/utils@3.2.6': resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==} + abs-svg-path@0.1.1: + resolution: {integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1990,6 +2295,9 @@ packages: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1997,6 +2305,13 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + base64-js@0.0.8: + resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==} + engines: {node: '>= 0.4'} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.10.38: resolution: {integrity: sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==} engines: {node: '>=6.0.0'} @@ -2006,6 +2321,9 @@ packages: resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + brace-expansion@1.1.15: resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} @@ -2017,6 +2335,12 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + brotli@1.3.3: + resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==} + + browserify-zlib@0.2.0: + resolution: {integrity: sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==} + browserslist@4.28.2: resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -2057,6 +2381,9 @@ packages: caniuse-lite@1.0.30001799: resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} @@ -2069,6 +2396,18 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + check-error@2.1.3: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} @@ -2076,6 +2415,10 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + clone@2.1.2: + resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} + engines: {node: '>=0.8'} + cluster-key-slot@1.1.1: resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} engines: {node: '>=0.10.0'} @@ -2087,6 +2430,17 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + color-name@2.1.0: + resolution: {integrity: sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==} + engines: {node: '>=12.20'} + + color-string@2.1.4: + resolution: {integrity: sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==} + engines: {node: '>=18'} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -2136,6 +2490,9 @@ packages: supports-color: optional: true + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -2163,6 +2520,12 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + dfa@1.2.0: + resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==} + diff-match-patch@1.0.5: resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==} @@ -2286,6 +2649,9 @@ packages: electron-to-chromium@1.5.376: resolution: {integrity: sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==} + emoji-regex-xs@1.0.0: + resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} + emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} @@ -2369,6 +2735,10 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + eslint-config-next@15.5.19: resolution: {integrity: sha512-UZwkuhBCNxVZfo93MSHRDOVNWXooJJGcAUyTAVIp0+9QFhH4SqJxWY0s6Mk9C2kMi777HPMn3dseOrZshWpG9Q==} peerDependencies: @@ -2485,6 +2855,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -2492,6 +2865,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + eventsource-parser@3.1.0: resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} engines: {node: '>=18.0.0'} @@ -2500,6 +2877,9 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2525,6 +2905,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -2544,6 +2927,9 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + fontkit@2.0.4: + resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==} + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -2646,6 +3032,27 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hsl-to-hex@1.0.0: + resolution: {integrity: sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==} + + hsl-to-rgb-for-reals@1.1.1: + resolution: {integrity: sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==} + + html-url-attributes@3.0.1: + resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + + hyphen@1.14.1: + resolution: {integrity: sha512-kvL8xYl5QMTh+LwohVN72ciOxC0OEV79IPdJSTwEXok9y9QHebXGdFgrED4sWfiax/ODx++CAMk3hMy4XPJPOw==} + + icu-minify@4.13.0: + resolution: {integrity: sha512-SIFMeUHZJjzS5RvIGvybKvWoHjDm9cGVEs2EpJ8PmywOdJLWyblPm7TdPLLoUtkJtwQD7iGhl2WMptZ+N0on+w==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -2662,10 +3069,19 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + intl-messageformat@11.2.8: + resolution: {integrity: sha512-l323RCl3qJDVQ8U9j74ut/hVMdg3VPsOHpVMDvFfz9qiq4dPO5ooVYFNVUzzrpgG39a+RLzcXyJb8VFgIU+tUA==} + ioredis@5.10.1: resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} engines: {node: '>=12.22.0'} @@ -2674,6 +3090,12 @@ packages: resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==} engines: {node: '>=12.22.0'} + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -2709,6 +3131,9 @@ packages: resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} engines: {node: '>= 0.4'} + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-document.all@1.0.0: resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} engines: {node: '>= 0.4'} @@ -2729,6 +3154,9 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-map@2.0.3: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} @@ -2745,6 +3173,10 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -2769,6 +3201,9 @@ packages: resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} engines: {node: '>= 0.4'} + is-url@1.2.4: + resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==} + is-weakmap@2.0.2: resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} engines: {node: '>= 0.4'} @@ -2795,6 +3230,9 @@ packages: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} + jay-peg@1.1.1: + resolution: {integrity: sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==} + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -2802,6 +3240,9 @@ packages: jose@6.2.3: resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-md5@0.8.3: + resolution: {integrity: sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2943,6 +3384,9 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + linebreak@1.1.0: + resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -2956,6 +3400,9 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -2973,14 +3420,152 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-newline-to-break@2.0.0: + resolution: {integrity: sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + media-engine@1.0.3: + resolution: {integrity: sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -3022,6 +3607,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + next-auth@5.0.0-beta.31: resolution: {integrity: sha512-1OBgCKPzo+S7UWWMp3xgvGvIJ0OpV7B3vR4ZDRqD9a4Ch+OT6dakLXG9ivhtmIWVa71nTSXattOHyCg8sNi8/Q==} peerDependencies: @@ -3038,6 +3627,19 @@ packages: nodemailer: optional: true + next-intl-swc-plugin-extractor@4.13.0: + resolution: {integrity: sha512-6S/fJI0KXvLCL8nhBo9P8eGaJPzmwJBTCzX0NaUIj0VyU8U89d//T+vjMLdNIXl5MlLaYH7B9MbAjb8Mvu+tqQ==} + + next-intl@4.13.0: + resolution: {integrity: sha512-OvNq2v5XLx4EkQOsAhVE9g+6zdb83XHusADCXXtIW4LILYnjEVaeINdr1lkVWKSjzwNUiMSlH5N4K0OQTRiv6A==} + peerDependencies: + next: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + next@15.5.19: resolution: {integrity: sha512-xNOW6tYshGX1/Oi3F8uuk4gpDeWsSUE/1Z0G5uUMekIxaQ0xc03UXd9II0VQHYMWviMeA0OHpJFAKsHf8bTYVg==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} @@ -3062,6 +3664,9 @@ packages: node-abort-controller@3.1.1: resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-exports-info@1.6.0: resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} engines: {node: '>= 0.4'} @@ -3078,6 +3683,9 @@ packages: resolution: {integrity: sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==} engines: {node: '>=6.0.0'} + normalize-svg-path@1.1.0: + resolution: {integrity: sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==} + oauth4webapi@3.8.6: resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==} @@ -3138,10 +3746,22 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + pako@0.2.9: + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + + parse-svg-path@0.1.2: + resolution: {integrity: sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==} + partial-json@0.1.7: resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} @@ -3218,10 +3838,19 @@ packages: engines: {node: '>=18'} hasBin: true + png-js@2.0.0: + resolution: {integrity: sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==} + + po-parser@2.1.1: + resolution: {integrity: sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + postcss@8.4.31: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} @@ -3266,6 +3895,9 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -3273,6 +3905,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + queue@6.0.2: + resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==} + react-dom@19.2.7: resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} peerDependencies: @@ -3281,6 +3916,12 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-markdown@10.1.0: + resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} + peerDependencies: + '@types/react': '>=18' + react: '>=18' + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -3305,6 +3946,28 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + remark-breaks@4.0.0: + resolution: {integrity: sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==} + + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + remark@15.0.1: + resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -3317,6 +3980,9 @@ packages: engines: {node: '>= 0.4'} hasBin: true + restructure@3.0.2: + resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -3333,6 +3999,9 @@ packages: resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} engines: {node: '>=0.4'} + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-push-apply@1.0.0: resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} engines: {node: '>= 0.4'} @@ -3341,6 +4010,9 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + scheduler@0.25.0-rc-603e6108-20241029: + resolution: {integrity: sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -3422,6 +4094,9 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} @@ -3465,6 +4140,12 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -3476,6 +4157,12 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -3497,6 +4184,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svg-arc-to-cubic-bezier@3.2.0: + resolution: {integrity: sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==} + swr@2.4.1: resolution: {integrity: sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==} peerDependencies: @@ -3513,6 +4203,9 @@ packages: resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==} engines: {node: '>=18'} + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -3539,6 +4232,12 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -3591,6 +4290,30 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + unicode-properties@1.4.1: + resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==} + + unicode-trie@2.0.0: + resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + unrs-resolver@1.12.2: resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} @@ -3603,11 +4326,29 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-intl@4.13.0: + resolution: {integrity: sha512-fAFDrWaASxlhXOipcOyb5VDD+YONqj6+8O8EcG/J7RBoOUF3A8YahRWLN+mBxYMrlMQB8N6Voqk5X+YC+HSL0A==} + peerDependencies: + react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 + use-sync-external-store@1.6.0: resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite-compatible-readable-stream@3.6.1: + resolution: {integrity: sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==} + engines: {node: '>= 6'} + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -3727,6 +4468,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yoga-layout@3.2.1: + resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + zod-to-json-schema@3.25.2: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: @@ -3735,6 +4479,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + snapshots: '@ai-sdk/amazon-bedrock@4.0.119(zod@3.25.76)': @@ -3988,6 +4735,8 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 + '@babel/runtime@7.29.7': {} + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -4381,6 +5130,18 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@formatjs/fast-memoize@3.1.6': {} + + '@formatjs/icu-messageformat-parser@3.5.11': + dependencies: + '@formatjs/icu-skeleton-parser': 2.1.10 + + '@formatjs/icu-skeleton-parser@2.1.10': {} + + '@formatjs/intl-localematcher@0.8.10': + dependencies: + '@formatjs/fast-memoize': 3.1.6 + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -4572,6 +5333,10 @@ snapshots: '@next/swc-win32-x64-msvc@15.5.19': optional: true + '@noble/ciphers@1.3.0': {} + + '@noble/hashes@1.8.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -4590,12 +5355,176 @@ snapshots: '@panva/hkdf@1.2.1': {} + '@parcel/watcher-android-arm64@2.5.6': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.6': + optional: true + + '@parcel/watcher-darwin-x64@2.5.6': + optional: true + + '@parcel/watcher-freebsd-x64@2.5.6': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-arm-musl@2.5.6': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.5.6': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-x64-musl@2.5.6': + optional: true + + '@parcel/watcher-win32-arm64@2.5.6': + optional: true + + '@parcel/watcher-win32-ia32@2.5.6': + optional: true + + '@parcel/watcher-win32-x64@2.5.6': + optional: true + + '@parcel/watcher@2.5.6': + dependencies: + detect-libc: 2.1.2 + is-glob: 4.0.3 + node-addon-api: 7.1.1 + picomatch: 4.0.4 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.5.6 + '@parcel/watcher-darwin-arm64': 2.5.6 + '@parcel/watcher-darwin-x64': 2.5.6 + '@parcel/watcher-freebsd-x64': 2.5.6 + '@parcel/watcher-linux-arm-glibc': 2.5.6 + '@parcel/watcher-linux-arm-musl': 2.5.6 + '@parcel/watcher-linux-arm64-glibc': 2.5.6 + '@parcel/watcher-linux-arm64-musl': 2.5.6 + '@parcel/watcher-linux-x64-glibc': 2.5.6 + '@parcel/watcher-linux-x64-musl': 2.5.6 + '@parcel/watcher-win32-arm64': 2.5.6 + '@parcel/watcher-win32-ia32': 2.5.6 + '@parcel/watcher-win32-x64': 2.5.6 + '@petamoriken/float16@3.9.3': {} '@playwright/test@1.61.0': dependencies: playwright: 1.61.0 + '@react-pdf/fns@3.1.3': {} + + '@react-pdf/font@4.0.8': + dependencies: + '@react-pdf/pdfkit': 5.1.1 + '@react-pdf/types': 2.11.1 + fontkit: 2.0.4 + is-url: 1.2.4 + + '@react-pdf/image@3.1.0': + dependencies: + '@react-pdf/svg': 1.1.0 + jay-peg: 1.1.1 + png-js: 2.0.0 + + '@react-pdf/layout@4.6.1': + dependencies: + '@react-pdf/fns': 3.1.3 + '@react-pdf/image': 3.1.0 + '@react-pdf/primitives': 4.3.0 + '@react-pdf/stylesheet': 6.2.1 + '@react-pdf/textkit': 6.3.0 + '@react-pdf/types': 2.11.1 + emoji-regex-xs: 1.0.0 + queue: 6.0.2 + yoga-layout: 3.2.1 + + '@react-pdf/pdfkit@5.1.1': + dependencies: + '@babel/runtime': 7.29.7 + '@noble/ciphers': 1.3.0 + '@noble/hashes': 1.8.0 + browserify-zlib: 0.2.0 + fontkit: 2.0.4 + jay-peg: 1.1.1 + js-md5: 0.8.3 + linebreak: 1.1.0 + png-js: 2.0.0 + vite-compatible-readable-stream: 3.6.1 + + '@react-pdf/primitives@4.3.0': {} + + '@react-pdf/reconciler@2.0.0(react@19.2.7)': + dependencies: + object-assign: 4.1.1 + react: 19.2.7 + scheduler: 0.25.0-rc-603e6108-20241029 + + '@react-pdf/render@4.5.1': + dependencies: + '@babel/runtime': 7.29.7 + '@react-pdf/fns': 3.1.3 + '@react-pdf/primitives': 4.3.0 + '@react-pdf/textkit': 6.3.0 + '@react-pdf/types': 2.11.1 + abs-svg-path: 0.1.1 + color-string: 2.1.4 + normalize-svg-path: 1.1.0 + parse-svg-path: 0.1.2 + svg-arc-to-cubic-bezier: 3.2.0 + + '@react-pdf/renderer@4.5.1(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@react-pdf/fns': 3.1.3 + '@react-pdf/font': 4.0.8 + '@react-pdf/layout': 4.6.1 + '@react-pdf/pdfkit': 5.1.1 + '@react-pdf/primitives': 4.3.0 + '@react-pdf/reconciler': 2.0.0(react@19.2.7) + '@react-pdf/render': 4.5.1 + '@react-pdf/types': 2.11.1 + events: 3.3.0 + object-assign: 4.1.1 + prop-types: 15.8.1 + queue: 6.0.2 + react: 19.2.7 + + '@react-pdf/stylesheet@6.2.1': + dependencies: + '@react-pdf/fns': 3.1.3 + '@react-pdf/types': 2.11.1 + color-string: 2.1.4 + hsl-to-hex: 1.0.0 + media-engine: 1.0.3 + postcss-value-parser: 4.2.0 + + '@react-pdf/svg@1.1.0': + dependencies: + '@react-pdf/primitives': 4.3.0 + + '@react-pdf/textkit@6.3.0': + dependencies: + '@react-pdf/fns': 3.1.3 + bidi-js: 1.0.3 + hyphen: 1.14.1 + unicode-properties: 1.4.1 + + '@react-pdf/types@2.11.1': + dependencies: + '@react-pdf/font': 4.0.8 + '@react-pdf/primitives': 4.3.0 + '@react-pdf/stylesheet': 6.2.1 + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/rollup-android-arm-eabi@4.62.2': @@ -4677,6 +5606,8 @@ snapshots: '@rushstack/eslint-patch@1.16.1': {} + '@schummar/icu-type-parser@1.21.5': {} + '@smithy/core@3.25.1': dependencies: '@aws-crypto/crc32': 5.2.0 @@ -4713,10 +5644,70 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@swc/core-darwin-arm64@1.15.41': + optional: true + + '@swc/core-darwin-x64@1.15.41': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.15.41': + optional: true + + '@swc/core-linux-arm64-gnu@1.15.41': + optional: true + + '@swc/core-linux-arm64-musl@1.15.41': + optional: true + + '@swc/core-linux-ppc64-gnu@1.15.41': + optional: true + + '@swc/core-linux-s390x-gnu@1.15.41': + optional: true + + '@swc/core-linux-x64-gnu@1.15.41': + optional: true + + '@swc/core-linux-x64-musl@1.15.41': + optional: true + + '@swc/core-win32-arm64-msvc@1.15.41': + optional: true + + '@swc/core-win32-ia32-msvc@1.15.41': + optional: true + + '@swc/core-win32-x64-msvc@1.15.41': + optional: true + + '@swc/core@1.15.41': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.27 + optionalDependencies: + '@swc/core-darwin-arm64': 1.15.41 + '@swc/core-darwin-x64': 1.15.41 + '@swc/core-linux-arm-gnueabihf': 1.15.41 + '@swc/core-linux-arm64-gnu': 1.15.41 + '@swc/core-linux-arm64-musl': 1.15.41 + '@swc/core-linux-ppc64-gnu': 1.15.41 + '@swc/core-linux-s390x-gnu': 1.15.41 + '@swc/core-linux-x64-gnu': 1.15.41 + '@swc/core-linux-x64-musl': 1.15.41 + '@swc/core-win32-arm64-msvc': 1.15.41 + '@swc/core-win32-ia32-msvc': 1.15.41 + '@swc/core-win32-x64-msvc': 1.15.41 + + '@swc/counter@0.1.3': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 + '@swc/types@0.1.27': + dependencies: + '@swc/counter': 0.1.3 + '@tailwindcss/node@4.3.1': dependencies: '@jridgewell/remapping': 2.3.5 @@ -4821,16 +5812,34 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} '@types/diff-match-patch@1.0.36': {} + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.9 + '@types/estree@1.0.9': {} + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/ms@2.1.0': {} + '@types/node@22.19.21': dependencies: undici-types: 6.21.0 @@ -4853,6 +5862,10 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + '@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -4944,6 +5957,8 @@ snapshots: '@typescript-eslint/types': 8.61.1 eslint-visitor-keys: 5.0.1 + '@ungap/structured-clone@1.3.1': {} + '@unrs/resolver-binding-android-arm-eabi@1.12.2': optional: true @@ -5081,6 +6096,8 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 + abs-svg-path@0.1.1: {} + acorn-jsx@5.3.2(acorn@8.17.0): dependencies: acorn: 8.17.0 @@ -5197,14 +6214,24 @@ snapshots: axobject-query@4.1.0: {} + bail@2.0.2: {} + balanced-match@1.0.2: {} balanced-match@4.0.4: {} + base64-js@0.0.8: {} + + base64-js@1.5.1: {} + baseline-browser-mapping@2.10.38: {} bcryptjs@3.0.3: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + brace-expansion@1.1.15: dependencies: balanced-match: 1.0.2 @@ -5218,6 +6245,14 @@ snapshots: dependencies: fill-range: 7.1.1 + brotli@1.3.3: + dependencies: + base64-js: 1.5.1 + + browserify-zlib@0.2.0: + dependencies: + pako: 1.0.11 + browserslist@4.28.2: dependencies: baseline-browser-mapping: 2.10.38 @@ -5262,6 +6297,8 @@ snapshots: caniuse-lite@1.0.30001799: {} + ccount@2.0.1: {} + chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -5277,10 +6314,20 @@ snapshots: chalk@5.6.2: {} + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + check-error@2.1.3: {} client-only@0.0.1: {} + clone@2.1.2: {} + cluster-key-slot@1.1.1: {} color-convert@2.0.1: @@ -5289,6 +6336,14 @@ snapshots: color-name@1.1.4: {} + color-name@2.1.0: {} + + color-string@2.1.4: + dependencies: + color-name: 2.1.0 + + comma-separated-tokens@2.0.3: {} + concat-map@0.0.1: {} convert-source-map@2.0.0: {} @@ -5333,6 +6388,10 @@ snapshots: dependencies: ms: 2.1.3 + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + deep-eql@5.0.2: {} deep-is@0.1.4: {} @@ -5355,6 +6414,12 @@ snapshots: detect-libc@2.1.2: {} + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + dfa@1.2.0: {} + diff-match-patch@1.0.5: {} doctrine@2.1.0: @@ -5401,6 +6466,8 @@ snapshots: electron-to-chromium@1.5.376: {} + emoji-regex-xs@1.0.0: {} + emoji-regex@9.2.2: {} enhanced-resolve@5.21.6: @@ -5642,6 +6709,8 @@ snapshots: escape-string-regexp@4.0.0: {} + escape-string-regexp@5.0.0: {} + eslint-config-next@15.5.19(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3): dependencies: '@next/eslint-plugin-next': 15.5.19 @@ -5838,16 +6907,22 @@ snapshots: estraverse@5.3.0: {} + estree-util-is-identifier-name@3.0.0: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 esutils@2.0.3: {} + events@3.3.0: {} + eventsource-parser@3.1.0: {} expect-type@1.3.0: {} + extend@3.0.2: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.1: @@ -5870,6 +6945,8 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fflate@0.8.3: {} + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -5890,6 +6967,18 @@ snapshots: flatted@3.4.2: {} + fontkit@2.0.4: + dependencies: + '@swc/helpers': 0.5.15 + brotli: 1.3.3 + clone: 2.1.2 + dfa: 1.2.0 + fast-deep-equal: 3.1.3 + restructure: 3.0.2 + tiny-inflate: 1.0.3 + unicode-properties: 1.4.1 + unicode-trie: 2.0.0 + for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -6000,6 +7089,44 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.9 + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hsl-to-hex@1.0.0: + dependencies: + hsl-to-rgb-for-reals: 1.1.1 + + hsl-to-rgb-for-reals@1.1.1: {} + + html-url-attributes@3.0.1: {} + + hyphen@1.14.1: {} + + icu-minify@4.13.0: + dependencies: + '@formatjs/icu-messageformat-parser': 3.5.11 + ignore@5.3.2: {} ignore@7.0.5: {} @@ -6011,12 +7138,21 @@ snapshots: imurmurhash@0.1.4: {} + inherits@2.0.4: {} + + inline-style-parser@0.2.7: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 hasown: 2.0.4 side-channel: 1.1.1 + intl-messageformat@11.2.8: + dependencies: + '@formatjs/fast-memoize': 3.1.6 + '@formatjs/icu-messageformat-parser': 3.5.11 + ioredis@5.10.1: dependencies: '@ioredis/commands': 1.5.1 @@ -6043,6 +7179,13 @@ snapshots: transitivePeerDependencies: - supports-color + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.9 @@ -6087,6 +7230,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-decimal@2.0.1: {} + is-document.all@1.0.0: dependencies: call-bound: 1.0.4 @@ -6109,6 +7254,8 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-hexadecimal@2.0.1: {} + is-map@2.0.3: {} is-negative-zero@2.0.3: {} @@ -6120,6 +7267,8 @@ snapshots: is-number@7.0.0: {} + is-plain-obj@4.1.0: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -6148,6 +7297,8 @@ snapshots: dependencies: which-typed-array: 1.1.22 + is-url@1.2.4: {} + is-weakmap@2.0.2: {} is-weakref@1.1.1: @@ -6174,10 +7325,16 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 + jay-peg@1.1.1: + dependencies: + restructure: 3.0.2 + jiti@2.7.0: {} jose@6.2.3: {} + js-md5@0.8.3: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -6287,6 +7444,11 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + linebreak@1.1.0: + dependencies: + base64-js: 0.0.8 + unicode-trie: 2.0.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -6297,6 +7459,8 @@ snapshots: lodash.merge@4.6.2: {} + longest-streak@3.1.0: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -6313,10 +7477,363 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + markdown-table@3.0.4: {} + math-intrinsics@1.1.0: {} + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-newline-to-break@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-find-and-replace: 3.0.2 + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.1 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + media-engine@1.0.3: {} + merge2@1.4.1: {} + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -6358,6 +7875,8 @@ snapshots: natural-compare@1.4.0: {} + negotiator@1.0.0: {} + next-auth@5.0.0-beta.31(next@15.5.19(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(nodemailer@9.0.1)(react@19.2.7): dependencies: '@auth/core': 0.41.2(nodemailer@9.0.1) @@ -6366,6 +7885,25 @@ snapshots: optionalDependencies: nodemailer: 9.0.1 + next-intl-swc-plugin-extractor@4.13.0: {} + + next-intl@4.13.0(next@15.5.19(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(typescript@5.9.3): + dependencies: + '@formatjs/intl-localematcher': 0.8.10 + '@parcel/watcher': 2.5.6 + '@swc/core': 1.15.41 + icu-minify: 4.13.0 + negotiator: 1.0.0 + next: 15.5.19(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next-intl-swc-plugin-extractor: 4.13.0 + po-parser: 2.1.1 + react: 19.2.7 + use-intl: 4.13.0(react@19.2.7) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@swc/helpers' + next@15.5.19(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: '@next/env': 15.5.19 @@ -6393,6 +7931,8 @@ snapshots: node-abort-controller@3.1.1: {} + node-addon-api@7.1.1: {} + node-exports-info@1.6.0: dependencies: array.prototype.flatmap: 1.3.3 @@ -6409,6 +7949,10 @@ snapshots: nodemailer@9.0.1: {} + normalize-svg-path@1.1.0: + dependencies: + svg-arc-to-cubic-bezier: 3.2.0 + oauth4webapi@3.8.6: {} object-assign@4.1.1: {} @@ -6484,10 +8028,26 @@ snapshots: dependencies: p-limit: 3.1.0 + pako@0.2.9: {} + + pako@1.0.11: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + + parse-svg-path@0.1.2: {} + partial-json@0.1.7: {} path-exists@4.0.0: {} @@ -6549,8 +8109,16 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + png-js@2.0.0: + dependencies: + fflate: 0.8.3 + + po-parser@2.1.1: {} + possible-typed-array-names@1.1.0: {} + postcss-value-parser@4.2.0: {} + postcss@8.4.31: dependencies: nanoid: 3.3.13 @@ -6589,10 +8157,16 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + property-information@7.2.0: {} + punycode@2.3.1: {} queue-microtask@1.2.3: {} + queue@6.0.2: + dependencies: + inherits: 2.0.4 + react-dom@19.2.7(react@19.2.7): dependencies: react: 19.2.7 @@ -6600,6 +8174,24 @@ snapshots: react-is@16.13.1: {} + react-markdown@10.1.0(@types/react@19.2.17)(react@19.2.7): + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/react': 19.2.17 + devlop: 1.1.0 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + mdast-util-to-hast: 13.2.1 + react: 19.2.7 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + react-refresh@0.17.0: {} react@19.2.7: {} @@ -6630,6 +8222,57 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + remark-breaks@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-newline-to-break: 2.0.0 + unified: 11.0.5 + + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + + remark@15.0.1: + dependencies: + '@types/mdast': 4.0.4 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -6643,6 +8286,8 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + restructure@3.0.2: {} + reusify@1.1.0: {} rollup@4.62.2: @@ -6688,6 +8333,8 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 + safe-buffer@5.2.1: {} + safe-push-apply@1.0.0: dependencies: es-errors: 1.3.0 @@ -6699,6 +8346,8 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + scheduler@0.25.0-rc-603e6108-20241029: {} + scheduler@0.27.0: {} secure-json-parse@2.7.0: {} @@ -6812,6 +8461,8 @@ snapshots: source-map@0.6.1: {} + space-separated-tokens@2.0.2: {} + split2@4.2.0: {} stable-hash@0.0.5: {} @@ -6878,6 +8529,15 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.2 + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + strip-bom@3.0.0: {} strip-json-comments@3.1.1: {} @@ -6886,6 +8546,14 @@ snapshots: dependencies: js-tokens: 9.0.1 + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.7): dependencies: client-only: 0.0.1 @@ -6899,6 +8567,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svg-arc-to-cubic-bezier@3.2.0: {} + swr@2.4.1(react@19.2.7): dependencies: dequal: 2.0.3 @@ -6911,6 +8581,8 @@ snapshots: throttleit@2.1.0: {} + tiny-inflate@1.0.3: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -6930,6 +8602,10 @@ snapshots: dependencies: is-number: 7.0.0 + trim-lines@3.0.1: {} + + trough@2.2.0: {} + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -6999,6 +8675,49 @@ snapshots: undici-types@6.21.0: {} + unicode-properties@1.4.1: + dependencies: + base64-js: 1.5.1 + unicode-trie: 2.0.0 + + unicode-trie@2.0.0: + dependencies: + pako: 0.2.9 + tiny-inflate: 1.0.3 + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + unrs-resolver@1.12.2: dependencies: napi-postinstall: 0.3.4 @@ -7036,10 +8755,36 @@ snapshots: dependencies: punycode: 2.3.1 + use-intl@4.13.0(react@19.2.7): + dependencies: + '@formatjs/fast-memoize': 3.1.6 + '@schummar/icu-type-parser': 1.21.5 + icu-minify: 4.13.0 + intl-messageformat: 11.2.8 + react: 19.2.7 + use-sync-external-store@1.6.0(react@19.2.7): dependencies: react: 19.2.7 + util-deprecate@1.0.2: {} + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite-compatible-readable-stream@3.6.1: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + vite-node@3.2.4(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4): dependencies: cac: 6.7.14 @@ -7076,7 +8821,7 @@ snapshots: lightningcss: 1.32.0 tsx: 4.22.4 - vitest@3.2.6(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4): + vitest@3.2.6(@types/debug@4.1.13)(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.6 @@ -7102,6 +8847,7 @@ snapshots: vite-node: 3.2.4(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4) why-is-node-running: 2.3.0 optionalDependencies: + '@types/debug': 4.1.13 '@types/node': 22.19.21 transitivePeerDependencies: - jiti @@ -7179,8 +8925,12 @@ snapshots: yocto-queue@0.1.0: {} + yoga-layout@3.2.1: {} + zod-to-json-schema@3.25.2(zod@3.25.76): dependencies: zod: 3.25.76 zod@3.25.76: {} + + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3c11f27..8f9d285 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,6 @@ allowBuilds: + '@parcel/watcher': true + '@swc/core': true esbuild: true msgpackr-extract: true sharp: true diff --git a/src/app/admin/blueprints/[id]/blueprint-detail-client.tsx b/src/app/admin/blueprints/[id]/blueprint-detail-client.tsx new file mode 100644 index 0000000..a8d5aef --- /dev/null +++ b/src/app/admin/blueprints/[id]/blueprint-detail-client.tsx @@ -0,0 +1,223 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { useState } from 'react'; + +type VerifyStatus = 'pending' | 'pass' | 'fail' | 'uncertain'; + +export type BlueprintDetail = { + blueprint: { + id: string; + title: string; + intentKey: string; + status: 'draft' | 'verifying' | 'published' | 'flagged'; + contentVersion: number; + modelVersion: string; + promptVersion: number; + staleAt: Date | null; + }; + concepts: { id: string; ord: number; name: string }[]; + lessons: { + id: string; + ord: number; + estMinutes: number; + locale: string; + segments: { + id: string; + ord: number; + text: string; + verifyStatus: VerifyStatus; + difficultyLevel: number; + checkpoints: { + id: string; + prompt: string; + kind: 'predict' | 'explain' | 'solve'; + referenceAnswer: string; + rubric: unknown; + verifyStatus: VerifyStatus; + }[]; + }[]; + }[]; +}; + +const STATUS_COLOR: Record = { + pass: { fg: 'var(--color-mastered, #1a6e2e)', bg: 'var(--color-mastered-bg, #eaf5ea)' }, + fail: { fg: 'var(--color-misconception)', bg: 'var(--color-misconception-subtle)' }, + uncertain: { fg: 'var(--color-ink-muted)', bg: 'var(--color-surface)' }, + pending: { fg: 'var(--color-ink-faint)', bg: 'var(--color-surface)' }, +}; + +function StatusBadge({ status }: { status: VerifyStatus }) { + const c = STATUS_COLOR[status]; + return {status}; +} + +export default function BlueprintDetailClient({ detail }: { detail: BlueprintDetail }) { + const router = useRouter(); + const { blueprint, concepts, lessons } = detail; + const [busy, setBusy] = useState(null); + const [msg, setMsg] = useState(null); + + const reverify = async (lessonId: string, runT2: boolean) => { + if (!confirm(`Re-verify this lesson${runT2 ? ' at T2 (strong model — higher cost)' : ''}? This calls the LLM and may incur cost.`)) return; + setBusy(lessonId); + setMsg(null); + const res = await fetch(`/api/admin/blueprints/${blueprint.id}/verify`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ lessonId, runT2 }), + }); + setBusy(null); + setMsg(res.ok ? 'Re-verification complete.' : 'Re-verification failed — check server logs.'); + router.refresh(); + }; + + const forceRegenerate = async () => { + if (!confirm('Force regenerate this blueprint? This will mark it stale and enqueue a paid regeneration job.')) return; + setBusy('__blueprint__'); + setMsg(null); + const res = await fetch(`/api/admin/blueprints/${blueprint.id}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'regenerate' }), + }); + setBusy(null); + setMsg(res.ok ? 'Regeneration job enqueued.' : 'Failed to enqueue — check server logs.'); + router.refresh(); + }; + + const setContentStatus = async (kind: 'segment' | 'checkpoint', id: string, verifyStatus: VerifyStatus) => { + setBusy(id); + setMsg(null); + await fetch('/api/admin/content-status', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ kind, id, verifyStatus }), + }); + setBusy(null); + router.refresh(); + }; + + return ( +
+ ← Blueprints +
+

{blueprint.title}

+ +
+

+ {blueprint.intentKey} · content v{blueprint.contentVersion} · model {blueprint.modelVersion} · prompt v{blueprint.promptVersion} · status {blueprint.status} + {blueprint.staleAt && <> · ⚠ stale since {new Date(blueprint.staleAt).toLocaleDateString()}} +

+ + + {concepts.length > 0 && ( +
+

Concepts

+
    + {concepts.map((c) =>
  1. {c.name}
  2. )} +
+
+ )} + + {msg &&

{msg}

} + + {lessons.length === 0 &&

No lessons generated yet for this blueprint.

} + + {lessons.map((lesson) => ( +
+
+

Lesson {lesson.ord + 1} · ~{lesson.estMinutes} min · {lesson.locale}

+
+ + +
+
+ + {lesson.segments.length === 0 &&

No segments.

} + + {lesson.segments.map((seg) => ( +
+
+ Segment {seg.ord + 1} · difficulty {seg.difficultyLevel} +
+ + {seg.verifyStatus !== 'fail' ? ( + + ) : ( + + )} +
+
+

{seg.text || empty segment body}

+ + {seg.checkpoints.map((cp) => ( +
+
+ {cp.kind} +
+ + {cp.verifyStatus !== 'fail' ? ( + + ) : ( + + )} +
+
+

{cp.prompt}

+
+ Reference answer & rubric +

{cp.referenceAnswer}

+
{JSON.stringify(cp.rubric, null, 2)}
+
+
+ ))} +
+ ))} +
+ ))} +
+ ); +} + +const s = { + back: { display: 'inline-block', color: 'var(--color-ink-muted)', textDecoration: 'none', fontFamily: 'var(--font-display)', fontSize: '0.875rem', marginBottom: 'var(--space-4)' }, + header: { display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }, + heading: { fontFamily: 'var(--font-display)', fontSize: '1.5rem', fontWeight: 500, color: 'var(--color-ink)' }, + meta: { fontFamily: 'var(--font-display)', fontSize: '0.8125rem', color: 'var(--color-ink-faint)', marginTop: 'var(--space-1)', marginBottom: 'var(--space-6)' }, + section: { marginBottom: 'var(--space-6)' }, + h2: { fontFamily: 'var(--font-display)', fontSize: '1.0625rem', fontWeight: 600, color: 'var(--color-ink)', margin: 0 }, + conceptList: { margin: 'var(--space-2) 0 0', paddingLeft: 'var(--space-5)', color: 'var(--color-ink)', fontFamily: 'var(--font-display)', fontSize: '0.9375rem' }, + conceptItem: { marginBottom: 'var(--space-1)' }, + toast: { padding: 'var(--space-2) var(--space-3)', background: 'var(--color-surface)', border: '1px solid var(--color-border)', borderRadius: '4px', fontSize: '0.875rem', color: 'var(--color-ink)' }, + empty: { color: 'var(--color-ink-faint)', fontSize: '0.9375rem' }, + lesson: { border: '1px solid var(--color-border)', borderRadius: '6px', padding: 'var(--space-5)', marginBottom: 'var(--space-5)' }, + lessonHead: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 'var(--space-3)', flexWrap: 'wrap' as const, marginBottom: 'var(--space-4)' }, + lessonMeta: { fontWeight: 400, color: 'var(--color-ink-faint)', fontSize: '0.875rem' }, + actionRow: { display: 'flex', gap: 'var(--space-2)', alignItems: 'center', flexWrap: 'wrap' as const }, + segment: { borderTop: '1px solid var(--color-border-subtle)', paddingTop: 'var(--space-4)', marginTop: 'var(--space-4)' }, + segHead: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 'var(--space-2)', flexWrap: 'wrap' as const }, + segLabel: { fontFamily: 'var(--font-display)', fontSize: '0.8125rem', fontWeight: 600, color: 'var(--color-ink-muted)' }, + segText: { fontFamily: 'var(--font-body)', fontSize: '1rem', lineHeight: 1.7, color: 'var(--color-ink)', marginTop: 'var(--space-2)', maxWidth: '64ch' }, + checkpoint: { background: 'var(--color-surface)', border: '1px solid var(--color-border-subtle)', borderRadius: '4px', padding: 'var(--space-3)', marginTop: 'var(--space-3)' }, + cpKind: { fontFamily: 'var(--font-display)', fontSize: '0.75rem', fontWeight: 600, textTransform: 'uppercase' as const, letterSpacing: '0.08em', color: 'var(--color-accent)' }, + cpPrompt: { fontFamily: 'var(--font-body)', fontSize: '0.9375rem', lineHeight: 1.6, color: 'var(--color-ink)', marginTop: 'var(--space-2)' }, + details: { marginTop: 'var(--space-2)' }, + summary: { fontFamily: 'var(--font-display)', fontSize: '0.8125rem', color: 'var(--color-ink-muted)', cursor: 'pointer' }, + refAnswer: { fontFamily: 'var(--font-body)', fontSize: '0.875rem', lineHeight: 1.6, color: 'var(--color-ink-muted)', marginTop: 'var(--space-2)' }, + rubric: { fontFamily: 'monospace', fontSize: '0.75rem', background: 'var(--color-bg)', padding: 'var(--space-2)', borderRadius: '4px', overflowX: 'auto' as const, color: 'var(--color-ink-muted)' }, + badge: { fontFamily: 'var(--font-display)', fontSize: '0.6875rem', fontWeight: 600, textTransform: 'uppercase' as const, letterSpacing: '0.06em', padding: '2px 8px', borderRadius: '999px' }, + btn: { padding: 'var(--space-1) var(--space-3)', background: 'var(--color-accent-subtle)', color: 'var(--color-accent)', border: '1px solid var(--color-accent)', borderRadius: '4px', cursor: 'pointer', fontSize: '0.8125rem' }, + flagBtn: { padding: 'var(--space-1) var(--space-3)', background: 'var(--color-misconception-subtle)', color: 'var(--color-misconception)', border: '1px solid var(--color-misconception)', borderRadius: '4px', cursor: 'pointer', fontSize: '0.8125rem' }, +} as const; diff --git a/src/app/admin/blueprints/[id]/page.tsx b/src/app/admin/blueprints/[id]/page.tsx new file mode 100644 index 0000000..7de8000 --- /dev/null +++ b/src/app/admin/blueprints/[id]/page.tsx @@ -0,0 +1,101 @@ +import { notFound } from 'next/navigation'; +import { asc, eq, inArray } from 'drizzle-orm'; +import { requireAdmin } from '@/lib/auth-helpers'; +import { db } from '@/lib/db'; +import { blueprints, concepts, lessons, segments, checkpoints } from '@/lib/db/schema'; +import BlueprintDetailClient, { type BlueprintDetail } from './blueprint-detail-client'; + +export default async function AdminBlueprintDetailPage({ params }: { params: Promise<{ id: string }> }) { + await requireAdmin(); + const { id } = await params; + + const [bp] = await db + .select({ + id: blueprints.id, + title: blueprints.title, + intentKey: blueprints.intentKey, + status: blueprints.status, + contentVersion: blueprints.contentVersion, + modelVersion: blueprints.modelVersion, + promptVersion: blueprints.promptVersion, + staleAt: blueprints.staleAt, + }) + .from(blueprints) + .where(eq(blueprints.id, id)) + .limit(1); + + if (!bp) notFound(); + + const conceptRows = await db + .select({ id: concepts.id, ord: concepts.ord, name: concepts.name }) + .from(concepts) + .where(eq(concepts.blueprintId, id)) + .orderBy(asc(concepts.ord)); + + const lessonRows = await db + .select({ id: lessons.id, ord: lessons.ord, estMinutes: lessons.estMinutes, locale: lessons.locale }) + .from(lessons) + .where(eq(lessons.blueprintId, id)) + .orderBy(asc(lessons.ord)); + + const lessonIds = lessonRows.map((l) => l.id); + const segRows = lessonIds.length + ? await db + .select({ + id: segments.id, + lessonId: segments.lessonId, + ord: segments.ord, + bodyJson: segments.bodyJson, + verifyStatus: segments.verifyStatus, + difficultyLevel: segments.difficultyLevel, + }) + .from(segments) + .where(inArray(segments.lessonId, lessonIds)) + .orderBy(asc(segments.ord)) + : []; + + const segIds = segRows.map((s) => s.id); + const cpRows = segIds.length + ? await db + .select({ + id: checkpoints.id, + segmentId: checkpoints.segmentId, + prompt: checkpoints.prompt, + kind: checkpoints.kind, + referenceAnswer: checkpoints.referenceAnswer, + rubricJson: checkpoints.rubricJson, + verifyStatus: checkpoints.verifyStatus, + }) + .from(checkpoints) + .where(inArray(checkpoints.segmentId, segIds)) + : []; + + const detail: BlueprintDetail = { + blueprint: bp, + concepts: conceptRows, + lessons: lessonRows.map((l) => ({ + ...l, + segments: segRows + .filter((s) => s.lessonId === l.id) + .map((s) => ({ + id: s.id, + ord: s.ord, + text: (s.bodyJson as { text?: string })?.text ?? '', + verifyStatus: s.verifyStatus, + difficultyLevel: s.difficultyLevel, + checkpoints: cpRows + .filter((c) => c.segmentId === s.id) + .map((c) => ({ + id: c.id, + prompt: c.prompt, + kind: c.kind, + referenceAnswer: c.referenceAnswer, + rubric: c.rubricJson, + verifyStatus: c.verifyStatus, + })), + })), + })), + }; + + return ; +} diff --git a/src/app/admin/blueprints/blueprints-client.tsx b/src/app/admin/blueprints/blueprints-client.tsx index f236b8d..4f9b2b8 100644 --- a/src/app/admin/blueprints/blueprints-client.tsx +++ b/src/app/admin/blueprints/blueprints-client.tsx @@ -1,6 +1,7 @@ 'use client'; import { useRouter } from 'next/navigation'; +import Link from 'next/link'; import { useState } from 'react'; type Blueprint = { id: string; intentKey: string; title: string; status: 'draft' | 'verifying' | 'published' | 'flagged'; createdAt: Date }; @@ -11,6 +12,23 @@ const STATUS_OPTIONS: Status[] = ['draft', 'verifying', 'published', 'flagged']; export default function AdminBlueprintsClient({ blueprints }: { blueprints: Blueprint[] }) { const router = useRouter(); const [busy, setBusy] = useState(null); + const [selected, setSelected] = useState>(new Set()); + const [deleting, setDeleting] = useState(false); + + const allChecked = blueprints.length > 0 && selected.size === blueprints.length; + const someChecked = selected.size > 0 && !allChecked; + + const toggleAll = () => { + setSelected(allChecked ? new Set() : new Set(blueprints.map(b => b.id))); + }; + + const toggleOne = (id: string) => { + setSelected(prev => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + }; const setStatus = async (id: string, status: Status) => { setBusy(id); @@ -23,48 +41,128 @@ export default function AdminBlueprintsClient({ blueprints }: { blueprints: Blue router.refresh(); }; + const deleteSelected = async () => { + if (selected.size === 0) return; + const label = selected.size === 1 ? '1 blueprint' : `${selected.size} blueprints`; + if (!confirm(`Delete ${label} and all their lessons, segments, and grades? This cannot be undone.`)) return; + setDeleting(true); + await fetch('/api/admin/blueprints', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids: [...selected] }), + }); + setSelected(new Set()); + setDeleting(false); + router.refresh(); + }; + + const deleteAll = async () => { + if (!confirm(`Delete ALL ${blueprints.length} blueprints and all their lessons, segments, and grades? This cannot be undone.`)) return; + setDeleting(true); + await fetch('/api/admin/blueprints', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ all: true }), + }); + setSelected(new Set()); + setDeleting(false); + router.refresh(); + }; + return (
-

Blueprints

-
- - - - {['Title', 'Intent key', 'Status', 'Created', 'Set status'].map((h) => ( - - ))} - - - - {blueprints.map((bp) => ( - - - - - - - - ))} - -
{h}
{bp.title}{bp.intentKey}{bp.status}{new Date(bp.createdAt).toLocaleDateString()} - -
+
+

Blueprints

+
+ {selected.size > 0 && ( + + )} + {blueprints.length > 0 && ( + + )} +
+ + {blueprints.length === 0 ? ( +

No blueprints yet.

+ ) : ( +
+ + + + + {['Title', 'Intent key', 'Status', 'Created', 'Set status'].map((h) => ( + + ))} + + + + {blueprints.map((bp) => ( + + + + + + + + + ))} + +
+ { if (el) el.indeterminate = someChecked; }} + onChange={toggleAll} + aria-label="Select all blueprints" + /> + {h}
+ toggleOne(bp.id)} + aria-label={`Select ${bp.title}`} + /> + + {bp.title} + {bp.intentKey}{bp.status}{new Date(bp.createdAt).toLocaleDateString()} + +
+
+ )}
); } const s = { - heading: { fontFamily: 'var(--font-display)', fontSize: '1.5rem', fontWeight: 500, color: 'var(--color-ink)', marginBottom: 'var(--space-6)' }, + heading: { fontFamily: 'var(--font-display)', fontSize: '1.5rem', fontWeight: 500, color: 'var(--color-ink)', margin: 0 }, + empty: { fontFamily: 'var(--font-display)', fontSize: '0.9375rem', color: 'var(--color-ink-faint)' }, table: { width: '100%', borderCollapse: 'collapse' as const, fontFamily: 'var(--font-display)', fontSize: '0.875rem' }, th: { textAlign: 'left' as const, padding: 'var(--space-2) var(--space-3)', borderBottom: '2px solid var(--color-border)', color: 'var(--color-ink-faint)', fontWeight: 600 }, - td: { padding: 'var(--space-2) var(--space-3)', borderBottom: '1px solid var(--color-border-subtle)', color: 'var(--color-ink)' }, + td: { padding: 'var(--space-2) var(--space-3)', borderBottom: '1px solid var(--color-border-subtle)', color: 'var(--color-ink)', verticalAlign: 'middle' as const }, select: { padding: 'var(--space-1) var(--space-2)', border: '1px solid var(--color-border)', borderRadius: '4px', background: 'var(--color-surface)', color: 'var(--color-ink)', fontSize: '0.875rem' }, + titleLink: { color: 'var(--color-accent)', textDecoration: 'none', fontWeight: 500 }, + rowSelected: { background: 'var(--color-surface-raised)' }, + btn: { fontFamily: 'var(--font-display)', fontSize: '0.875rem', fontWeight: 500, padding: 'var(--space-2) var(--space-4)', borderRadius: '4px', cursor: 'pointer', border: 'none' }, + btnDanger: { background: 'var(--color-misconception)', color: '#fff' }, + btnDangerOutline: { background: 'transparent', color: 'var(--color-misconception)', border: '1px solid var(--color-misconception)' }, } as const; diff --git a/src/app/admin/invites/invites-client.tsx b/src/app/admin/invites/invites-client.tsx new file mode 100644 index 0000000..497a2a7 --- /dev/null +++ b/src/app/admin/invites/invites-client.tsx @@ -0,0 +1,186 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; + +export type InviteRow = { + id: string; + email: string | null; + role: 'learner' | 'admin' | 'suspended'; + token: string; + expires: Date; + acceptedAt: Date | null; + createdAt: Date; +}; + +function inviteUrl(token: string): string { + const origin = typeof window !== 'undefined' ? window.location.origin : ''; + return `${origin}/auth/accept-invite?token=${encodeURIComponent(token)}`; +} + +function statusOf(inv: InviteRow): 'accepted' | 'expired' | 'pending' { + if (inv.acceptedAt) return 'accepted'; + if (new Date(inv.expires).getTime() < Date.now()) return 'expired'; + return 'pending'; +} + +export default function AdminInvitesClient({ invites }: { invites: InviteRow[] }) { + const router = useRouter(); + const [email, setEmail] = useState(''); + const [role, setRole] = useState<'learner' | 'admin'>('learner'); + const [creating, setCreating] = useState(false); + const [error, setError] = useState(null); + const [lastLink, setLastLink] = useState(null); + const [copied, setCopied] = useState(null); + const [busy, setBusy] = useState(null); + + const copy = async (url: string, key: string) => { + try { + await navigator.clipboard.writeText(url); + setCopied(key); + setTimeout(() => setCopied((c) => (c === key ? null : c)), 1500); + } catch { + setError('Could not copy to clipboard'); + } + }; + + const create = async (e: React.FormEvent) => { + e.preventDefault(); + setCreating(true); + setError(null); + setLastLink(null); + const res = await fetch('/api/admin/invites', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: email.trim() || undefined, role }), + }); + setCreating(false); + if (res.ok) { + const d = (await res.json()) as { url: string }; + setLastLink(d.url); + setEmail(''); + setRole('learner'); + router.refresh(); + } else { + const d = (await res.json()) as { error?: string }; + setError(d.error ?? 'Failed to create invite'); + } + }; + + const revoke = async (id: string) => { + if (!confirm('Revoke this invite? Its link will stop working.')) return; + setBusy(id); + await fetch(`/api/admin/invites/${id}`, { method: 'DELETE' }); + setBusy(null); + router.refresh(); + }; + + return ( +
+

Invites

+ +
+
+ + setEmail(e.target.value)} + placeholder="person@example.com" + style={s.input} + /> +
+
+ + +
+ +
+ + {error &&

{error}

} + + {lastLink && ( +
+

Invite link {email ? '' : '(share this — no email was sent)'}:

+
+ {lastLink} + +
+
+ )} + +
+ + + + {['Email', 'Role', 'Status', 'Created', 'Actions'].map((h) => )} + + + + {invites.length === 0 && ( + + )} + {invites.map((inv) => { + const status = statusOf(inv); + return ( + + + + + + + + ); + })} + +
{h}
No invites yet.
{inv.email ?? link only}{inv.role}{status}{new Date(inv.createdAt).toLocaleDateString()} + {status === 'pending' && ( + <> + + + + )} + {status !== 'pending' && ( + + )} +
+
+
+ ); +} + +function badgeStyle(status: 'accepted' | 'expired' | 'pending'): { color: string; background: string } { + if (status === 'accepted') return { color: 'var(--color-mastered, #1a6e2e)', background: 'var(--color-mastered-bg, #eaf5ea)' }; + if (status === 'expired') return { color: 'var(--color-ink-faint)', background: 'var(--color-surface)' }; + return { color: 'var(--color-accent)', background: 'var(--color-accent-subtle)' }; +} + +const s = { + heading: { fontFamily: 'var(--font-display)', fontSize: '1.5rem', fontWeight: 500, color: 'var(--color-ink)', marginBottom: 'var(--space-6)' }, + form: { display: 'flex', gap: 'var(--space-4)', alignItems: 'flex-end', flexWrap: 'wrap' as const, maxWidth: '40rem' }, + field: { display: 'flex', flexDirection: 'column' as const, gap: 'var(--space-1)' }, + label: { fontFamily: 'var(--font-display)', fontSize: '0.8125rem', fontWeight: 500, color: 'var(--color-ink-muted)' }, + input: { padding: 'var(--space-2) var(--space-3)', background: 'var(--color-surface)', border: '1px solid var(--color-border)', borderRadius: '4px', fontFamily: 'var(--font-body)', fontSize: '0.9375rem', color: 'var(--color-ink)', outline: 'none', minWidth: '16rem' }, + select: { padding: 'var(--space-2) var(--space-3)', border: '1px solid var(--color-border)', borderRadius: '4px', background: 'var(--color-surface)', color: 'var(--color-ink)', fontSize: '0.9375rem' }, + createBtn: { padding: 'var(--space-2) var(--space-5)', background: 'var(--color-accent)', color: 'var(--color-on-accent)', border: 'none', borderRadius: '4px', fontFamily: 'var(--font-display)', fontSize: '0.9375rem', fontWeight: 500, cursor: 'pointer' }, + error: { color: 'var(--color-misconception)', fontSize: '0.875rem', marginTop: 'var(--space-3)' }, + linkBox: { marginTop: 'var(--space-4)', padding: 'var(--space-3) var(--space-4)', background: 'var(--color-surface)', border: '1px solid var(--color-border)', borderRadius: '6px', maxWidth: '48rem' }, + linkLabel: { fontFamily: 'var(--font-display)', fontSize: '0.8125rem', color: 'var(--color-ink-muted)', margin: '0 0 var(--space-2)' }, + linkRow: { display: 'flex', gap: 'var(--space-2)', alignItems: 'center', flexWrap: 'wrap' as const }, + linkCode: { fontFamily: 'monospace', fontSize: '0.8125rem', color: 'var(--color-ink)', wordBreak: 'break-all' as const, flex: 1 }, + table: { width: '100%', borderCollapse: 'collapse' as const, fontFamily: 'var(--font-display)', fontSize: '0.875rem' }, + th: { textAlign: 'left' as const, padding: 'var(--space-2) var(--space-3)', borderBottom: '2px solid var(--color-border)', color: 'var(--color-ink-faint)', fontWeight: 600 }, + td: { padding: 'var(--space-2) var(--space-3)', borderBottom: '1px solid var(--color-border-subtle)', color: 'var(--color-ink)' }, + muted: { color: 'var(--color-ink-faint)' }, + badge: { fontSize: '0.6875rem', fontWeight: 600, textTransform: 'uppercase' as const, letterSpacing: '0.06em', padding: '2px 8px', borderRadius: '999px' }, + btn: { padding: 'var(--space-1) var(--space-3)', background: 'var(--color-accent-subtle)', color: 'var(--color-accent)', border: '1px solid var(--color-accent)', borderRadius: '4px', cursor: 'pointer', fontSize: '0.8125rem' }, + dangerBtn: { padding: 'var(--space-1) var(--space-3)', background: 'var(--color-misconception-subtle)', color: 'var(--color-misconception)', border: '1px solid var(--color-misconception)', borderRadius: '4px', cursor: 'pointer', fontSize: '0.8125rem' }, +} as const; diff --git a/src/app/admin/invites/page.tsx b/src/app/admin/invites/page.tsx new file mode 100644 index 0000000..6fadc7b --- /dev/null +++ b/src/app/admin/invites/page.tsx @@ -0,0 +1,24 @@ +import { desc } from 'drizzle-orm'; +import { requireAdmin } from '@/lib/auth-helpers'; +import { db } from '@/lib/db'; +import { invites } from '@/lib/db/schema'; +import AdminInvitesClient, { type InviteRow } from './invites-client'; + +export default async function AdminInvitesPage() { + await requireAdmin(); + + const rows: InviteRow[] = await db + .select({ + id: invites.id, + email: invites.email, + role: invites.role, + token: invites.token, + expires: invites.expires, + acceptedAt: invites.acceptedAt, + createdAt: invites.createdAt, + }) + .from(invites) + .orderBy(desc(invites.createdAt)); + + return ; +} diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index fd878c2..b6b59cb 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -13,8 +13,11 @@ export default async function AdminLayout({ children }: { children: ReactNode }) {[ { href: '/admin', label: 'Overview' }, { href: '/admin/users', label: 'Users' }, + { href: '/admin/invites', label: 'Invites' }, { href: '/admin/reports', label: 'Reports' }, { href: '/admin/blueprints', label: 'Blueprints' }, + { href: '/admin/misconceptions', label: 'Misconceptions' }, + { href: '/admin/quality', label: 'Quality' }, { href: '/admin/settings', label: 'Settings' }, ].map(({ href, label }) => (
  • diff --git a/src/app/admin/misconceptions/misconceptions-client.tsx b/src/app/admin/misconceptions/misconceptions-client.tsx new file mode 100644 index 0000000..f6fe543 --- /dev/null +++ b/src/app/admin/misconceptions/misconceptions-client.tsx @@ -0,0 +1,227 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import type { NovelQueueItem } from '@/lib/db/queries'; + +interface Props { + queue: NovelQueueItem[]; +} + +interface LabelFormState { + tag: string; + signature: string; + description: string; + correction: string; + addGolden: boolean; +} + +function EvidenceHighlight({ text, span }: { text: string; span: string }) { + if (!span || !text.includes(span)) { + return {text}; + } + const idx = text.indexOf(span); + return ( + + {text.slice(0, idx)} + {span} + {text.slice(idx + span.length)} + + ); +} + +function QueueCard({ item, onProcessed }: { item: NovelQueueItem; onProcessed: () => void }) { + const [busy, setBusy] = useState(false); + const [expanded, setExpanded] = useState(false); + const [form, setForm] = useState({ + tag: '', + signature: item.evidenceSpan, + description: '', + correction: '', + addGolden: false, + }); + const [msg, setMsg] = useState(null); + + const label = async () => { + if (!form.tag.trim() || !form.signature.trim()) return; + if (!item.conceptId) { setMsg('No concept linked — cannot label.'); return; } + setBusy(true); + setMsg(null); + const res = await fetch('/api/admin/misconceptions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'label', + queueId: item.id, + conceptId: item.conceptId, + tag: form.tag.trim(), + signature: form.signature.trim(), + description: form.description.trim() || undefined, + correction: form.correction.trim() || undefined, + addGolden: form.addGolden, + goldenData: form.addGolden ? { + responseText: item.responseText, + expectedTag: form.tag.trim(), + conceptId: item.conceptId, + } : undefined, + }), + }); + setBusy(false); + if (res.ok) { onProcessed(); } else { setMsg('Label failed — check server logs.'); } + }; + + const dismiss = async () => { + setBusy(true); + await fetch('/api/admin/misconceptions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'dismiss', queueId: item.id }), + }); + setBusy(false); + onProcessed(); + }; + + return ( +
    +
    +
    + {item.conceptName ?? 'Unknown concept'} + {new Date(item.createdAt).toLocaleDateString()} +
    +
    + + +
    +
    + +

    {item.checkpointPrompt}

    + +
    + +
    + + {item.existingMisconceptions.length > 0 && ( +
    + Existing misconceptions for this concept ({item.existingMisconceptions.length}) +
      + {item.existingMisconceptions.map((m) => ( +
    • + {m.tag} — {m.signature} +
    • + ))} +
    +
    + )} + + {expanded && ( +
    + + + + + + {msg &&

    {msg}

    } + +
    + )} +
    + ); +} + +export default function MisconceptionsClient({ queue }: Props) { + const router = useRouter(); + const [items, setItems] = useState(queue); + + const markProcessed = (id: string) => { + setItems((prev) => prev.filter((i) => i.id !== id)); + router.refresh(); + }; + + return ( +
    +

    Novel Misconceptions Queue

    +

    + {items.length === 0 + ? 'Queue is empty — all novel responses have been reviewed.' + : `${items.length} unprocessed response${items.length !== 1 ? 's' : ''} — label or dismiss each.`} +

    + + {items.map((item) => ( + markProcessed(item.id)} /> + ))} +
    + ); +} + +const s = { + heading: { fontFamily: 'var(--font-display)', fontSize: '1.5rem', fontWeight: 500, color: 'var(--color-ink)', marginBottom: 'var(--space-2)' }, + sub: { fontFamily: 'var(--font-display)', fontSize: '0.9375rem', color: 'var(--color-ink-muted)', marginBottom: 'var(--space-8)' }, + card: { border: '1px solid var(--color-border)', borderRadius: '6px', padding: 'var(--space-5)', marginBottom: 'var(--space-4)' }, + cardHead: { display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 'var(--space-3)', marginBottom: 'var(--space-3)', flexWrap: 'wrap' as const }, + conceptTag: { fontFamily: 'var(--font-display)', fontSize: '0.8125rem', fontWeight: 600, color: 'var(--color-accent)', textTransform: 'uppercase' as const, letterSpacing: '0.06em' }, + timestamp: { fontFamily: 'var(--font-display)', fontSize: '0.75rem', color: 'var(--color-ink-faint)', marginLeft: 'var(--space-3)' }, + actionRow: { display: 'flex', gap: 'var(--space-2)' }, + checkpointPrompt: { fontFamily: 'var(--font-body)', fontSize: '0.9375rem', color: 'var(--color-ink-muted)', fontStyle: 'italic', marginBottom: 'var(--space-3)' }, + responsePre: { fontFamily: 'var(--font-body)', fontSize: '0.9375rem', lineHeight: 1.65, color: 'var(--color-ink)', background: 'var(--color-surface)', border: '1px solid var(--color-border-subtle)', borderRadius: '4px', padding: 'var(--space-3)', marginBottom: 'var(--space-3)' }, + responseText: { whiteSpace: 'pre-wrap' as const }, + evidenceMark: { background: 'var(--color-misconception-subtle)', color: 'var(--color-misconception)', borderBottom: '2px solid var(--color-misconception)', padding: '0 2px', borderRadius: '2px' }, + details: { marginBottom: 'var(--space-3)' }, + summary: { fontFamily: 'var(--font-display)', fontSize: '0.8125rem', color: 'var(--color-ink-muted)', cursor: 'pointer' }, + miscList: { listStyle: 'none', padding: 0, margin: 'var(--space-2) 0 0' }, + miscItem: { fontFamily: 'var(--font-display)', fontSize: '0.8125rem', color: 'var(--color-ink-muted)', marginBottom: 'var(--space-1)' }, + labelForm: { borderTop: '1px solid var(--color-border-subtle)', paddingTop: 'var(--space-4)', marginTop: 'var(--space-3)', display: 'flex', flexDirection: 'column' as const, gap: 'var(--space-3)' }, + fieldLabel: { display: 'flex', flexDirection: 'column' as const, gap: 'var(--space-1)', fontFamily: 'var(--font-display)', fontSize: '0.8125rem', color: 'var(--color-ink-muted)' }, + input: { fontFamily: 'var(--font-display)', fontSize: '0.9375rem', color: 'var(--color-ink)', background: 'var(--color-surface)', border: '1px solid var(--color-border)', borderRadius: '4px', padding: 'var(--space-2) var(--space-3)', width: '100%' }, + error: { color: 'var(--color-misconception)', fontSize: '0.875rem' }, + labelBtn: { padding: 'var(--space-1) var(--space-3)', background: 'var(--color-accent-subtle)', color: 'var(--color-accent)', border: '1px solid var(--color-accent)', borderRadius: '4px', cursor: 'pointer', fontSize: '0.8125rem' }, + dismissBtn: { padding: 'var(--space-1) var(--space-3)', background: 'var(--color-surface)', color: 'var(--color-ink-muted)', border: '1px solid var(--color-border)', borderRadius: '4px', cursor: 'pointer', fontSize: '0.8125rem' }, + saveBtn: { alignSelf: 'flex-start', padding: 'var(--space-2) var(--space-5)', background: 'var(--color-accent)', color: 'var(--color-on-accent)', border: 'none', borderRadius: '4px', cursor: 'pointer', fontFamily: 'var(--font-display)', fontSize: '0.9375rem', fontWeight: 500 }, +} as const; diff --git a/src/app/admin/misconceptions/page.tsx b/src/app/admin/misconceptions/page.tsx new file mode 100644 index 0000000..25ee287 --- /dev/null +++ b/src/app/admin/misconceptions/page.tsx @@ -0,0 +1,9 @@ +import { requireAdmin } from '@/lib/auth-helpers'; +import { getNovelQueue } from '@/lib/db/queries'; +import MisconceptionsClient from './misconceptions-client'; + +export default async function AdminMisconceptionsPage() { + await requireAdmin(); + const queue = await getNovelQueue(30); + return ; +} diff --git a/src/app/admin/quality/page.tsx b/src/app/admin/quality/page.tsx new file mode 100644 index 0000000..23b583e --- /dev/null +++ b/src/app/admin/quality/page.tsx @@ -0,0 +1,202 @@ +import { requireAdmin } from '@/lib/auth-helpers'; +import { getEvalTrend, getOnlineGradeMetrics, getHighDisputeCheckpoints } from '@/lib/db/queries'; +import Link from 'next/link'; + +const FALSE_FAIL_THRESHOLD = 0.10; + +function pct(n: number) { return `${(n * 100).toFixed(1)}%`; } +function badge(v: number, threshold: number) { + const ok = v <= threshold; + return ( + + {ok ? 'PASS' : 'FAIL'} + + ); +} + +export default async function AdminQualityPage() { + await requireAdmin(); + + const [gradingTrend, onlineMetrics, highDispute] = await Promise.all([ + getEvalTrend('grading', 10), + getOnlineGradeMetrics(30), + getHighDisputeCheckpoints(1, 10), + ]); + + const latestRun = gradingTrend[0]; + + const s = { + heading: { fontFamily: 'var(--font-display)', fontSize: '1.5rem', fontWeight: 500, color: 'var(--color-ink)', marginBottom: 'var(--space-2)' }, + sub: { fontFamily: 'var(--font-display)', fontSize: '0.9375rem', color: 'var(--color-ink-muted)', marginBottom: 'var(--space-8)' }, + section: { marginBottom: 'var(--space-10)' }, + h2: { fontFamily: 'var(--font-display)', fontSize: '1.125rem', fontWeight: 600, color: 'var(--color-ink)', marginBottom: 'var(--space-4)' }, + metric: { display: 'flex', alignItems: 'center', gap: 'var(--space-3)', marginBottom: 'var(--space-3)' }, + metricLabel: { fontFamily: 'var(--font-display)', fontSize: '0.9375rem', color: 'var(--color-ink-muted)', minWidth: '16rem' }, + metricValue: { fontFamily: 'var(--font-display)', fontSize: '1rem', fontWeight: 600, color: 'var(--color-ink)' }, + threshold: { fontFamily: 'var(--font-display)', fontSize: '0.8125rem', color: 'var(--color-ink-faint)', marginLeft: 'var(--space-2)' }, + table: { width: '100%', borderCollapse: 'collapse' as const, fontFamily: 'var(--font-display)', fontSize: '0.875rem' }, + th: { textAlign: 'left' as const, padding: 'var(--space-2) var(--space-3)', borderBottom: '2px solid var(--color-border)', color: 'var(--color-ink-muted)', fontWeight: 600, fontSize: '0.75rem', textTransform: 'uppercase' as const, letterSpacing: '0.06em' }, + td: { padding: 'var(--space-2) var(--space-3)', borderBottom: '1px solid var(--color-border-subtle)', color: 'var(--color-ink)' }, + empty: { color: 'var(--color-ink-faint)', fontSize: '0.9375rem', fontFamily: 'var(--font-display)' }, + link: { color: 'var(--color-accent)', textDecoration: 'none', fontFamily: 'var(--font-display)', fontSize: '0.875rem' }, + } as const; + + return ( +
    +

    Quality Dashboard

    +

    False-fail rate, verdict distribution, and high-dispute checkpoints.

    + + {/* ── Latest golden eval ────────────────────────────────────────────── */} +
    +

    Golden Eval — Latest Grading Run

    + {!latestRun ? ( +

    No eval runs persisted yet. Run PERSIST_EVAL_RUNS=1 pnpm test:golden.

    + ) : ( + <> +
    + False-fail rate + {pct(latestRun.falseFailRate ?? 0)} + threshold: {pct(FALSE_FAIL_THRESHOLD)} + {badge(latestRun.falseFailRate ?? 0, FALSE_FAIL_THRESHOLD)} +
    +
    + Verdict accuracy + {pct(latestRun.accuracy ?? 0)} + floor: 70% + {badge(1 - (latestRun.accuracy ?? 0), 0.30)} +
    +
    + Cases / Passed + {latestRun.passed} / {latestRun.total} +
    +
    + Model + {latestRun.modelVersion} +
    +
    + Run date + {latestRun.createdAt.toLocaleDateString()} +
    + + )} +
    + + {/* ── Trend table ───────────────────────────────────────────────────── */} + {gradingTrend.length > 1 && ( +
    +

    Grading Eval History

    +
    + + + + {['Date', 'False-fail', 'Accuracy', 'Total', 'Model'].map((h) => ( + + ))} + + + + {gradingTrend.map((run) => ( + + + + + + + + ))} + +
    {h}
    {run.createdAt.toLocaleDateString()} FALSE_FAIL_THRESHOLD ? 'var(--color-misconception)' : 'var(--color-mastered)' }}> + {pct(run.falseFailRate ?? 0)} + {pct(run.accuracy ?? 0)}{run.total}{run.modelVersion}
    +
    +
    + )} + + {/* ── Online metrics ────────────────────────────────────────────────── */} +
    +

    Production Metrics (last 30 days)

    +
    + Total grades + {onlineMetrics.totalGrades.toLocaleString()} +
    +
    + Grade-wrong dispute rate + {pct(onlineMetrics.gradeWrongRate)} + ({onlineMetrics.gradeWrongReports} reports) +
    +
    + Auto-flagged checkpoints + {onlineMetrics.autoFlaggedCheckpoints} +
    + {Object.entries(onlineMetrics.byVerdict).length > 0 && ( +
    +

    Verdict distribution

    +
    + + + + {['Verdict', 'Count', 'Share'].map((h) => )} + + + + {Object.entries(onlineMetrics.byVerdict) + .sort(([, a], [, b]) => b - a) + .map(([verdict, count]) => ( + + + + + + ))} + +
    {h}
    {verdict}{count} + {onlineMetrics.totalGrades > 0 ? pct(count / onlineMetrics.totalGrades) : '—'} +
    +
    +
    + )} +
    + + {/* ── High-dispute checkpoints ──────────────────────────────────────── */} +
    +

    High-Dispute Checkpoints

    + {highDispute.length === 0 ? ( +

    No disputed checkpoints. Good.

    + ) : ( +
    + + + + {['Blueprint', 'Checkpoint prompt', 'Disputes'].map((h) => )} + + + + {highDispute.map((cp) => ( + + + + + + ))} + +
    {h}
    + {cp.blueprintId ? ( + {cp.blueprintTitle} + ) : '—'} + + {cp.prompt} + {cp.disputeCount}
    +
    + )} +
    +
    + ); +} diff --git a/src/app/admin/reports/page.tsx b/src/app/admin/reports/page.tsx index 2295b70..275c524 100644 --- a/src/app/admin/reports/page.tsx +++ b/src/app/admin/reports/page.tsx @@ -1,15 +1,33 @@ import { requireAdmin } from '@/lib/auth-helpers'; import { db } from '@/lib/db'; -import { contentReports } from '@/lib/db/schema'; -import { asc } from 'drizzle-orm'; +import { contentReports, checkpoints, segments, lessons, blueprints } from '@/lib/db/schema'; +import { asc, eq } from 'drizzle-orm'; import AdminReportsClient from './reports-client'; export default async function AdminReportsPage() { await requireAdmin(); + // Resolve each report up the chain (checkpoint → segment → lesson → blueprint) + // so the admin can open the exact lesson the report is about. const rows = await db - .select() + .select({ + id: contentReports.id, + checkpointId: contentReports.checkpointId, + gradeId: contentReports.gradeId, + reason: contentReports.reason, + notes: contentReports.notes, + createdAt: contentReports.createdAt, + intentKey: blueprints.intentKey, + blueprintTitle: blueprints.title, + segmentOrd: segments.ord, + depth: lessons.depth, + locale: lessons.locale, + }) .from(contentReports) + .innerJoin(checkpoints, eq(contentReports.checkpointId, checkpoints.id)) + .innerJoin(segments, eq(checkpoints.segmentId, segments.id)) + .innerJoin(lessons, eq(segments.lessonId, lessons.id)) + .innerJoin(blueprints, eq(lessons.blueprintId, blueprints.id)) .orderBy(asc(contentReports.createdAt)); return ; diff --git a/src/app/admin/reports/reports-client.tsx b/src/app/admin/reports/reports-client.tsx index 2ac9fb5..9031719 100644 --- a/src/app/admin/reports/reports-client.tsx +++ b/src/app/admin/reports/reports-client.tsx @@ -1,9 +1,22 @@ 'use client'; +import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useState } from 'react'; -type Report = { id: string; checkpointId: string; gradeId: string | null; reason: string; notes: string | null; createdAt: Date }; +type Report = { + id: string; + checkpointId: string; + gradeId: string | null; + reason: string; + notes: string | null; + createdAt: Date; + intentKey: string; + blueprintTitle: string; + segmentOrd: number; + depth: string; + locale: string; +}; export default function AdminReportsClient({ reports }: { reports: Report[] }) { const router = useRouter(); @@ -34,9 +47,15 @@ export default function AdminReportsClient({ reports }: { reports: Report[] }) { {new Date(r.createdAt).toLocaleDateString()}
  • {r.notes &&

    {r.notes}

    } -

    - checkpoint {r.checkpointId.slice(0, 8)}… -

    + + {r.blueprintTitle} · segment {r.segmentOrd + 1} ↗ + +

    checkpoint {r.checkpointId.slice(0, 8)}…

    @@ -56,5 +75,7 @@ const s = { tag: { fontFamily: 'var(--font-display)', fontSize: '0.75rem', fontWeight: 600, padding: '2px 8px', background: 'var(--color-accent-subtle)', color: 'var(--color-accent)', borderRadius: '3px' }, date: { fontSize: '0.8125rem', color: 'var(--color-ink-faint)', fontFamily: 'var(--font-display)' }, notes: { fontSize: '0.9375rem', color: 'var(--color-ink-muted)' }, + lessonLink: { fontFamily: 'var(--font-display)', fontSize: '0.9375rem', fontWeight: 500, color: 'var(--color-accent)', textDecoration: 'none' }, + checkpointMeta: { fontSize: '0.75rem', color: 'var(--color-ink-faint)', fontFamily: 'var(--font-display)' }, btn: { padding: 'var(--space-1) var(--space-4)', background: 'var(--color-surface)', border: '1px solid var(--color-border)', borderRadius: '4px', cursor: 'pointer', fontFamily: 'var(--font-display)', fontSize: '0.875rem', color: 'var(--color-ink)' }, } as const; diff --git a/src/app/admin/settings/settings-client.tsx b/src/app/admin/settings/settings-client.tsx index de7dc54..474f308 100644 --- a/src/app/admin/settings/settings-client.tsx +++ b/src/app/admin/settings/settings-client.tsx @@ -86,5 +86,5 @@ const s = { label: { fontFamily: 'var(--font-display)', fontSize: '0.9375rem', fontWeight: 500, color: 'var(--color-ink)', margin: '0 0 var(--space-1)' }, desc: { fontFamily: 'var(--font-body)', fontSize: '0.875rem', color: 'var(--color-ink-muted)', margin: 0 }, toggle: { flexShrink: 0, padding: 'var(--space-2) var(--space-4)', border: '1px solid var(--color-border)', borderRadius: '4px', fontFamily: 'var(--font-display)', fontSize: '0.875rem', fontWeight: 500, cursor: 'pointer', background: 'var(--color-surface)', color: 'var(--color-ink-muted)' }, - toggleOn: { background: 'var(--color-accent)', color: '#fff', border: '1px solid var(--color-accent)' }, + toggleOn: { background: 'var(--color-accent)', color: 'var(--color-on-accent)', border: '1px solid var(--color-accent)' }, } as const; diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index d23a2ad..199def5 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -5,12 +5,12 @@ import { asc } from 'drizzle-orm'; import AdminUsersClient from './users-client'; export default async function AdminUsersPage() { - await requireAdmin(); + const session = await requireAdmin(); const rows = await db .select({ id: users.id, name: users.name, email: users.email, role: users.role, createdAt: users.createdAt }) .from(users) .orderBy(asc(users.createdAt)); - return ; + return ; } diff --git a/src/app/admin/users/users-client.tsx b/src/app/admin/users/users-client.tsx index fd2a207..810e08d 100644 --- a/src/app/admin/users/users-client.tsx +++ b/src/app/admin/users/users-client.tsx @@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation'; type UserRow = { id: string; name: string | null; email: string | null; role: 'learner' | 'admin' | 'suspended'; createdAt: Date }; -export default function AdminUsersClient({ users }: { users: UserRow[] }) { +export default function AdminUsersClient({ users, selfId }: { users: UserRow[]; selfId: string }) { const router = useRouter(); const [busy, setBusy] = useState(null); @@ -48,24 +48,35 @@ export default function AdminUsersClient({ users }: { users: UserRow[] }) { {u.role} {new Date(u.createdAt).toLocaleDateString()} - {u.role !== 'admin' && ( - + {u.id === selfId ? ( + You + ) : ( + <> + {u.role !== 'admin' && ( + + )} + {u.role === 'admin' && ( + + )} + {u.role !== 'suspended' && ( + + )} + {u.role === 'suspended' && ( + + )} + + )} - {u.role !== 'suspended' && ( - - )} - {u.role === 'suspended' && ( - - )} - ))} @@ -83,4 +94,5 @@ const s = { td: { padding: 'var(--space-2) var(--space-3)', borderBottom: '1px solid var(--color-border-subtle)', color: 'var(--color-ink)' }, btn: { padding: 'var(--space-1) var(--space-3)', background: 'var(--color-accent-subtle)', color: 'var(--color-accent)', border: '1px solid var(--color-accent)', borderRadius: '4px', cursor: 'pointer', fontSize: '0.8125rem' }, dangerBtn: { padding: 'var(--space-1) var(--space-3)', background: 'var(--color-misconception-subtle)', color: 'var(--color-misconception)', border: '1px solid var(--color-misconception)', borderRadius: '4px', cursor: 'pointer', fontSize: '0.8125rem' }, + selfNote: { color: 'var(--color-ink-faint)', fontSize: '0.8125rem' }, } as const; diff --git a/src/app/api/admin/blueprints/[id]/route.ts b/src/app/api/admin/blueprints/[id]/route.ts index 56170c4..d49ff35 100644 --- a/src/app/api/admin/blueprints/[id]/route.ts +++ b/src/app/api/admin/blueprints/[id]/route.ts @@ -2,13 +2,19 @@ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; import { eq } from 'drizzle-orm'; import { db } from '@/lib/db'; -import { blueprints } from '@/lib/db/schema'; +import { blueprints, jobs } from '@/lib/db/schema'; import { requireAdmin } from '@/lib/auth-helpers'; +import { markBlueprintStale } from '@/lib/generation/staleness'; +import { enqueueRegenerateBlueprint } from '@/lib/jobs/queue'; const PatchSchema = z.object({ status: z.enum(['draft', 'verifying', 'published', 'flagged']).optional(), }); +const PostSchema = z.object({ + action: z.enum(['mark-stale', 'regenerate']), +}); + export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }): Promise { await requireAdmin(); const { id } = await params; @@ -22,3 +28,56 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id await db.update(blueprints).set(parsed.data).where(eq(blueprints.id, id)); return NextResponse.json({ ok: true }); } + +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }): Promise { + await requireAdmin(); + const { id } = await params; + + let body: unknown; + try { body = await req.json(); } catch { return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); } + + const parsed = PostSchema.safeParse(body); + if (!parsed.success) return NextResponse.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, { status: 400 }); + + const [bp] = await db + .select({ id: blueprints.id, intentKey: blueprints.intentKey, contentVersion: blueprints.contentVersion }) + .from(blueprints) + .where(eq(blueprints.id, id)) + .limit(1); + + if (!bp) return NextResponse.json({ error: 'Blueprint not found' }, { status: 404 }); + + const targetVersion = await markBlueprintStale(id); + + if (parsed.data.action === 'regenerate') { + const idempotencyKey = `regenerate-blueprint:${id}:v${targetVersion}`; + const [existing] = await db + .select({ id: jobs.id }) + .from(jobs) + .where(eq(jobs.id, idempotencyKey)) + .limit(1); + + if (!existing) { + const [jobRow] = await db + .insert(jobs) + .values({ + type: 'regenerate-blueprint', + idempotencyKey, + status: 'pending', + payloadJson: { blueprintId: id, intentKey: bp.intentKey, targetContentVersion: targetVersion }, + }) + .returning({ id: jobs.id }); + + await enqueueRegenerateBlueprint({ + blueprintId: id, + intentKey: bp.intentKey, + targetContentVersion: targetVersion, + idempotencyKey: jobRow.id, + }); + } + + return NextResponse.json({ ok: true, enqueued: true, targetVersion }); + } + + return NextResponse.json({ ok: true, markedStale: true, targetVersion }); +} diff --git a/src/app/api/admin/blueprints/[id]/verify/route.ts b/src/app/api/admin/blueprints/[id]/verify/route.ts new file mode 100644 index 0000000..37cc309 --- /dev/null +++ b/src/app/api/admin/blueprints/[id]/verify/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { requireAdmin } from '@/lib/auth-helpers'; +import { verifyLesson } from '@/lib/verification/verify-content'; + +const Schema = z.object({ + lessonId: z.string().uuid(), + runT2: z.boolean().optional().default(false), +}); + +// Admin-triggered re-verification. This spends LLM budget — not on any serve +// path — so it is gated behind requireAdmin and an explicit client confirm. +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }): Promise { + await requireAdmin(); + await params; // blueprint id is implied by the lesson; not needed directly + + let body: unknown; + try { body = await req.json(); } catch { return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); } + + const parsed = Schema.safeParse(body); + if (!parsed.success) return NextResponse.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, { status: 400 }); + + try { + const results = await verifyLesson({ lessonId: parsed.data.lessonId, runT2: parsed.data.runT2 }); + return NextResponse.json({ ok: true, results }); + } catch (err) { + return NextResponse.json({ error: err instanceof Error ? err.message : 'Verification failed' }, { status: 500 }); + } +} diff --git a/src/app/api/admin/blueprints/route.ts b/src/app/api/admin/blueprints/route.ts index 20bad6a..24a6024 100644 --- a/src/app/api/admin/blueprints/route.ts +++ b/src/app/api/admin/blueprints/route.ts @@ -1,8 +1,15 @@ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; import { asc } from 'drizzle-orm'; import { db } from '@/lib/db'; import { blueprints } from '@/lib/db/schema'; import { requireAdmin } from '@/lib/auth-helpers'; +import { deleteBlueprints } from '@/lib/db/queries'; +import { z } from 'zod'; + +const DeleteBodySchema = z.union([ + z.object({ all: z.literal(true) }), + z.object({ ids: z.array(z.string().uuid()).min(1) }), +]); export async function GET(): Promise { await requireAdmin(); @@ -12,3 +19,23 @@ export async function GET(): Promise { .orderBy(asc(blueprints.createdAt)); return NextResponse.json({ blueprints: rows }); } + +export async function DELETE(req: NextRequest): Promise { + await requireAdmin(); + const body = await req.json().catch(() => null); + const parsed = DeleteBodySchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: 'Invalid body — send { ids: string[] } or { all: true }' }, { status: 400 }); + } + + let ids: string[]; + if ('all' in parsed.data) { + const rows = await db.select({ id: blueprints.id }).from(blueprints); + ids = rows.map(r => r.id); + } else { + ids = parsed.data.ids; + } + + await deleteBlueprints(ids); + return NextResponse.json({ deleted: ids.length }); +} diff --git a/src/app/api/admin/content-status/route.ts b/src/app/api/admin/content-status/route.ts new file mode 100644 index 0000000..20a9af3 --- /dev/null +++ b/src/app/api/admin/content-status/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { eq } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { segments, checkpoints } from '@/lib/db/schema'; +import { requireAdmin } from '@/lib/auth-helpers'; + +const Schema = z.object({ + kind: z.enum(['segment', 'checkpoint']), + id: z.string().uuid(), + verifyStatus: z.enum(['pending', 'pass', 'fail', 'uncertain']), +}); + +// Manual override of verify status — lets an admin flag (fail) or clear +// (pending) a specific segment/checkpoint after reviewing it by hand. +export async function PATCH(req: NextRequest): Promise { + await requireAdmin(); + + let body: unknown; + try { body = await req.json(); } catch { return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); } + + const parsed = Schema.safeParse(body); + if (!parsed.success) return NextResponse.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, { status: 400 }); + + const { kind, id, verifyStatus } = parsed.data; + if (kind === 'segment') { + await db.update(segments).set({ verifyStatus }).where(eq(segments.id, id)); + } else { + await db.update(checkpoints).set({ verifyStatus }).where(eq(checkpoints.id, id)); + } + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/admin/invites/[id]/route.ts b/src/app/api/admin/invites/[id]/route.ts new file mode 100644 index 0000000..91f2dc2 --- /dev/null +++ b/src/app/api/admin/invites/[id]/route.ts @@ -0,0 +1,13 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { eq } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { invites } from '@/lib/db/schema'; +import { requireAdmin } from '@/lib/auth-helpers'; + +// Revoke an invite. Deleting the row invalidates the token immediately. +export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }): Promise { + await requireAdmin(); + const { id } = await params; + await db.delete(invites).where(eq(invites.id, id)); + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/admin/invites/__tests__/route.test.ts b/src/app/api/admin/invites/__tests__/route.test.ts new file mode 100644 index 0000000..1819bef --- /dev/null +++ b/src/app/api/admin/invites/__tests__/route.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; + +const { mockSelect, mockInsert } = vi.hoisted(() => ({ + mockSelect: vi.fn(), + mockInsert: vi.fn(), +})); + +vi.mock('@/lib/db', () => ({ db: { select: mockSelect, insert: mockInsert } })); + +const { mockSendEmail } = vi.hoisted(() => ({ mockSendEmail: vi.fn().mockResolvedValue(undefined) })); +vi.mock('@/lib/email', () => ({ sendEmail: mockSendEmail })); + +import { POST } from '../route'; + +function makeRequest(body: unknown) { + return new NextRequest('http://localhost/api/admin/invites', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +function setup({ existingUser = false } = {}) { + // existing-email lookup + mockSelect.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue(existingUser ? [{ id: 'u1' }] : []) }), + }), + }); + mockInsert.mockReturnValue({ + values: vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue([{ id: 'invite-1' }]) }), + }); +} + +describe('POST /api/admin/invites', () => { + beforeEach(() => { + vi.clearAllMocks(); + setup(); + }); + + it('creates an email invite (201) and sends mail', async () => { + const res = await POST(makeRequest({ email: 'new@example.com', role: 'learner' })); + expect(res.status).toBe(201); + const json = await res.json(); + expect(json).toHaveProperty('token'); + expect(json).toHaveProperty('url'); + expect(mockSendEmail).toHaveBeenCalledOnce(); + }); + + it('creates a link-only invite without sending mail', async () => { + const res = await POST(makeRequest({ role: 'admin' })); + expect(res.status).toBe(201); + expect(mockSendEmail).not.toHaveBeenCalled(); + }); + + it('rejects an email that already belongs to a user (409)', async () => { + setup({ existingUser: true }); + const res = await POST(makeRequest({ email: 'taken@example.com', role: 'learner' })); + expect(res.status).toBe(409); + }); + + it('rejects an invalid role (400)', async () => { + const res = await POST(makeRequest({ role: 'superuser' })); + expect(res.status).toBe(400); + }); + + it('rejects suspended as an invite role (400)', async () => { + const res = await POST(makeRequest({ role: 'suspended' })); + expect(res.status).toBe(400); + }); + + it('returns 400 on invalid JSON', async () => { + const req = new NextRequest('http://localhost/api/admin/invites', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{bad', + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); +}); diff --git a/src/app/api/admin/invites/route.ts b/src/app/api/admin/invites/route.ts new file mode 100644 index 0000000..64e5f42 --- /dev/null +++ b/src/app/api/admin/invites/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { randomBytes } from 'crypto'; +import { desc, eq } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { invites, users } from '@/lib/db/schema'; +import { requireAdmin } from '@/lib/auth-helpers'; +import { sendEmail } from '@/lib/email'; +import { inviteEmail } from '@/lib/email/templates'; + +const BASE_URL = process.env.AUTH_URL ?? 'http://localhost:3000'; +const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days + +const CreateSchema = z.object({ + email: z.string().email().optional(), + role: z.enum(['learner', 'admin']).default('learner'), +}); + +export async function GET(): Promise { + await requireAdmin(); + const rows = await db + .select({ + id: invites.id, + email: invites.email, + role: invites.role, + token: invites.token, + expires: invites.expires, + acceptedAt: invites.acceptedAt, + createdAt: invites.createdAt, + }) + .from(invites) + .orderBy(desc(invites.createdAt)); + return NextResponse.json({ invites: rows }); +} + +export async function POST(req: NextRequest): Promise { + const session = await requireAdmin(); + + let body: unknown; + try { body = await req.json(); } catch { return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); } + + const parsed = CreateSchema.safeParse(body); + if (!parsed.success) return NextResponse.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, { status: 400 }); + + const { role } = parsed.data; + const email = parsed.data.email?.toLowerCase(); + + if (email) { + const [existing] = await db.select({ id: users.id }).from(users).where(eq(users.email, email)).limit(1); + if (existing) return NextResponse.json({ error: 'A user with that email already exists' }, { status: 409 }); + } + + const token = randomBytes(32).toString('hex'); + const expires = new Date(Date.now() + INVITE_TTL_MS); + + const [row] = await db + .insert(invites) + .values({ token, email: email ?? null, role, invitedBy: session.user.id, expires }) + .returning({ id: invites.id }); + + const url = `${BASE_URL}/auth/accept-invite?token=${encodeURIComponent(token)}`; + + if (email) { + const { subject, html, text } = inviteEmail(token, role); + void sendEmail({ to: email, subject, html, text }).catch(() => {}); + } + + return NextResponse.json({ id: row.id, token, url }, { status: 201 }); +} diff --git a/src/app/api/admin/misconceptions/route.ts b/src/app/api/admin/misconceptions/route.ts new file mode 100644 index 0000000..c610889 --- /dev/null +++ b/src/app/api/admin/misconceptions/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { requireAdmin } from '@/lib/auth-helpers'; +import { labelNovelMisconception, dismissNovelQueueItem } from '@/lib/db/queries'; +import { verifyMisconceptions } from '@/lib/verification/verify-misconceptions'; +import { llmClient } from '@/lib/llm/client'; +import { appendMisconceptionGoldenCase } from '@/lib/generation/golden-cases'; + +const LabelSchema = z.object({ + action: z.literal('label'), + queueId: z.string().uuid(), + conceptId: z.string().uuid(), + tag: z.string().min(1).max(80), + signature: z.string().min(1), + description: z.string().optional(), + correction: z.string().optional(), + addGolden: z.boolean().optional(), + goldenData: z.object({ + responseText: z.string(), + expectedTag: z.string(), + conceptId: z.string(), + }).optional(), +}); + +const DismissSchema = z.object({ + action: z.literal('dismiss'), + queueId: z.string().uuid(), +}); + +const BodySchema = z.discriminatedUnion('action', [LabelSchema, DismissSchema]); + +export async function POST(req: NextRequest): Promise { + await requireAdmin(); + + let body: unknown; + try { body = await req.json(); } catch { return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); } + + const parsed = BodySchema.safeParse(body); + if (!parsed.success) return NextResponse.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, { status: 400 }); + + if (parsed.data.action === 'dismiss') { + await dismissNovelQueueItem(parsed.data.queueId); + return NextResponse.json({ ok: true }); + } + + // label action + const { queueId, conceptId, tag, signature, description, correction, addGolden, goldenData } = parsed.data; + + const miscId = await labelNovelMisconception({ queueId, conceptId, tag, signature, description, correction }); + + // Verify the concept's full misconception library (T1) — non-blocking, failure is non-fatal + void verifyMisconceptions({ conceptId, client: llmClient }).catch((err) => + console.warn('[misconceptions] verify failed (non-fatal):', err), + ); + + // Optionally append to golden eval set + if (addGolden && goldenData) { + try { + await appendMisconceptionGoldenCase({ + responseText: goldenData.responseText, + expectedTag: goldenData.expectedTag, + conceptId: goldenData.conceptId, + tag, + signature, + }); + } catch (err) { + console.warn('[golden] append failed (non-fatal):', err); + } + } + + return NextResponse.json({ ok: true, misconceptionId: miscId }); +} diff --git a/src/app/api/admin/users/[id]/__tests__/route.test.ts b/src/app/api/admin/users/[id]/__tests__/route.test.ts index f9df4d4..764245e 100644 --- a/src/app/api/admin/users/[id]/__tests__/route.test.ts +++ b/src/app/api/admin/users/[id]/__tests__/route.test.ts @@ -96,6 +96,14 @@ describe('PATCH /api/admin/users/[id]', () => { }); expect(res.status).toBe(200); }); + + it('blocks an admin from changing their own role (400)', async () => { + const res = await PATCH(makeRequest({ role: 'learner' }), { + params: Promise.resolve({ id: 'test-admin-id' }), + }); + expect(res.status).toBe(400); + expect(vi.mocked(db).update).not.toHaveBeenCalled(); + }); }); describe('DELETE /api/admin/users/[id]', () => { @@ -150,4 +158,12 @@ describe('DELETE /api/admin/users/[id]', () => { }) ).rejects.toThrow('DB error'); }); + + it('blocks an admin from deleting their own account (400)', async () => { + const res = await DELETE(makeRequest(undefined, 'DELETE'), { + params: Promise.resolve({ id: 'test-admin-id' }), + }); + expect(res.status).toBe(400); + expect(vi.mocked(db).transaction).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts index 5a96292..e680fdf 100644 --- a/src/app/api/admin/users/[id]/route.ts +++ b/src/app/api/admin/users/[id]/route.ts @@ -10,9 +10,14 @@ const PatchSchema = z.object({ }); export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }): Promise { - await requireAdmin(); + const session = await requireAdmin(); const { id } = await params; + // Guard against self-lockout: an admin cannot demote or suspend their own account. + if (id === session.user.id) { + return NextResponse.json({ error: 'You cannot change your own role' }, { status: 400 }); + } + let body: unknown; try { body = await req.json(); } catch { return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); } @@ -24,9 +29,13 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id } export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }): Promise { - await requireAdmin(); + const session = await requireAdmin(); const { id } = await params; + if (id === session.user.id) { + return NextResponse.json({ error: 'You cannot delete your own account' }, { status: 400 }); + } + await db.transaction(async (tx) => { await tx.delete(responses).where(eq(responses.userId, id)); await tx.delete(mastery).where(eq(mastery.userId, id)); diff --git a/src/app/api/advance/__tests__/route.test.ts b/src/app/api/advance/__tests__/route.test.ts index f500758..5c509b5 100644 --- a/src/app/api/advance/__tests__/route.test.ts +++ b/src/app/api/advance/__tests__/route.test.ts @@ -5,10 +5,41 @@ vi.mock('@/lib/db/queries', () => ({ advanceJourneyPosition: vi.fn(), })); +vi.mock('@/lib/db', () => ({ + db: { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([{ lessonCount: 0 }]), + }), + }), + insert: vi.fn().mockReturnValue({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([{ id: 'job-1' }]), + }), + }), + }, +})); + +vi.mock('@/lib/db/schema', () => ({ + lessons: { blueprintId: 'bp', locale: 'locale', depth: 'depth', ord: 'ord' }, + concepts: { blueprintId: 'bp', ord: 'ord' }, + jobs: { id: 'id', idempotencyKey: 'ik' }, +})); + +vi.mock('@/lib/jobs/queue', () => ({ + enqueueGenerateLesson: vi.fn().mockResolvedValue('job-1'), +})); + +vi.mock('@/lib/auth-helpers', () => ({ + getOptionalSession: vi.fn().mockResolvedValue(null), +})); + import { POST } from '../route'; import { advanceJourneyPosition } from '@/lib/db/queries'; +import { db } from '@/lib/db'; const mockAdvance = vi.mocked(advanceJourneyPosition); +const mockDb = vi.mocked(db); const VALID_BODY = { userId: '123e4567-e89b-12d3-a456-426614174000', @@ -23,6 +54,14 @@ function makeRequest(body: unknown) { }); } +function mockSelectChain(results: Record[]) { + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(results), + }), + } as unknown as ReturnType); +} + describe('POST /api/advance', () => { beforeEach(() => vi.clearAllMocks()); @@ -57,16 +96,36 @@ describe('POST /api/advance', () => { expect(res.status).toBe(400); }); - it('returns 200 with position on success', async () => { + it('returns complete nextState when no lesson or concepts at next position', async () => { mockAdvance.mockResolvedValue(2); + // First call: lesson count = 0; second call: concept count = 0 + let call = 0; + mockDb.select.mockImplementation(() => ({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([call++ === 0 ? { lessonCount: 0 } : { conceptCount: 0 }]), + }), + }) as unknown as ReturnType); + const res = await POST(makeRequest(VALID_BODY)); expect(res.status).toBe(200); const body = await res.json(); expect(body.position).toBe(2); + expect(body.nextState).toBe('complete'); + }); + + it('returns ready nextState when lesson exists at next position', async () => { + mockAdvance.mockResolvedValue(1); + mockSelectChain([{ lessonCount: 1 }]); + + const res = await POST(makeRequest(VALID_BODY)); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.nextState).toBe('ready'); }); it('calls advanceJourneyPosition with correct args', async () => { mockAdvance.mockResolvedValue(1); + mockSelectChain([{ lessonCount: 1 }]); await POST(makeRequest(VALID_BODY)); expect(mockAdvance).toHaveBeenCalledWith(VALID_BODY.userId, VALID_BODY.blueprintId); }); diff --git a/src/app/api/advance/route.ts b/src/app/api/advance/route.ts index 6bf3739..16b427e 100644 --- a/src/app/api/advance/route.ts +++ b/src/app/api/advance/route.ts @@ -1,11 +1,19 @@ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; +import { eq, and, count } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { lessons, concepts, jobs } from '@/lib/db/schema'; import { advanceJourneyPosition } from '@/lib/db/queries'; import { getOptionalSession } from '@/lib/auth-helpers'; +import { enqueueGenerateLesson } from '@/lib/jobs/queue'; const AdvanceBodySchema = z.object({ userId: z.string().uuid().optional(), blueprintId: z.string().uuid(), + locale: z.string().optional(), + depth: z.string().optional(), + ageGroup: z.string().optional(), + intentKey: z.string().optional(), }); /** @@ -14,6 +22,12 @@ const AdvanceBodySchema = z.object({ * Advances the learner's journey position for a given blueprint. * Called when all checkpoints in a lesson are mastered. * Creates the journey instance on first call (anonymous-session-first). + * + * If no lesson exists at the new position, enqueues lazy generation when + * concepts are mapped to that ord. Returns `nextState`: + * - 'ready' lesson at next position already exists + * - 'generating' enqueued generation for the next position + * - 'complete' no more content — journey done */ export async function POST(req: NextRequest) { let body: unknown; @@ -25,7 +39,7 @@ export async function POST(req: NextRequest) { const parsed = AdvanceBodySchema.safeParse(body); if (!parsed.success) { - return NextResponse.json({ error: 'Missing userId or blueprintId' }, { status: 400 }); + return NextResponse.json({ error: 'Missing blueprintId' }, { status: 400 }); } const session = await getOptionalSession(); @@ -33,8 +47,70 @@ export async function POST(req: NextRequest) { if (!userId) { return NextResponse.json({ error: 'Missing userId or blueprintId' }, { status: 400 }); } - const { blueprintId } = parsed.data; - const position = await advanceJourneyPosition(userId, blueprintId); - return NextResponse.json({ position }); + const { blueprintId, locale = 'en', depth = 'standard', ageGroup = 'adult', intentKey } = parsed.data; + + const newPosition = await advanceJourneyPosition(userId, blueprintId); + + // Check if a lesson already exists at the new position. + const [{ lessonCount }] = await db + .select({ lessonCount: count() }) + .from(lessons) + .where( + and( + eq(lessons.blueprintId, blueprintId), + eq(lessons.locale, locale), + eq(lessons.depth, depth), + eq(lessons.ord, newPosition), + ), + ); + + if (Number(lessonCount) > 0) { + return NextResponse.json({ position: newPosition, nextState: 'ready' }); + } + + // No lesson at position N+1 — check if concepts are mapped to that ord. + const [{ conceptCount }] = await db + .select({ conceptCount: count() }) + .from(concepts) + .where(and(eq(concepts.blueprintId, blueprintId), eq(concepts.ord, newPosition))); + + if (Number(conceptCount) === 0) { + // No content planned at this ord — journey is complete. + return NextResponse.json({ position: newPosition, nextState: 'complete' }); + } + + // Enqueue generation for the next lesson (idempotency key scoped to blueprintId + ord). + if (intentKey) { + const idempotencyKey = `generate-lesson:${blueprintId}:ord${newPosition}:${locale}:${depth}:${ageGroup}`; + const [existingJob] = await db + .select({ id: jobs.id }) + .from(jobs) + .where(eq(jobs.idempotencyKey, idempotencyKey)) + .limit(1); + + if (!existingJob) { + const [jobRow] = await db + .insert(jobs) + .values({ + type: 'generate-lesson', + idempotencyKey, + status: 'pending', + payloadJson: { blueprintId, intentKey, locale, depth, ageGroup, ord: newPosition }, + }) + .returning({ id: jobs.id }); + + await enqueueGenerateLesson({ + blueprintId, + intentKey, + idempotencyKey: jobRow.id, + locale, + depth, + ageGroup, + ord: newPosition, + }); + } + } + + return NextResponse.json({ position: newPosition, nextState: 'generating' }); } diff --git a/src/app/api/auth/accept-invite/__tests__/route.test.ts b/src/app/api/auth/accept-invite/__tests__/route.test.ts new file mode 100644 index 0000000..6857436 --- /dev/null +++ b/src/app/api/auth/accept-invite/__tests__/route.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; + +const { mockSelect, mockTransaction } = vi.hoisted(() => ({ + mockSelect: vi.fn(), + mockTransaction: vi.fn(), +})); + +vi.mock('@/lib/db', () => ({ db: { select: mockSelect, transaction: mockTransaction } })); +vi.mock('bcryptjs', () => ({ default: { hash: vi.fn().mockResolvedValue('hashed_pw'), compare: vi.fn() } })); + +import { GET, POST } from '../route'; + +type Invite = { id: string; email: string | null; role: 'learner' | 'admin' }; + +function setup({ invite, existingUser = false }: { invite: Invite | null; existingUser?: boolean }) { + mockSelect.mockImplementation((fields: Record | undefined) => { + if (fields && 'role' in fields) { + // findValidInvite + return { from: () => ({ where: () => ({ limit: () => Promise.resolve(invite ? [invite] : []) }) }) }; + } + // existing-user check + return { from: () => ({ where: () => ({ limit: () => Promise.resolve(existingUser ? [{ id: 'u1' }] : []) }) }) }; + }); + + const txInsert = vi.fn().mockReturnValue({ values: vi.fn().mockResolvedValue(undefined) }); + const txUpdate = vi.fn().mockReturnValue({ set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) }); + mockTransaction.mockImplementation(async (fn: (tx: unknown) => Promise) => fn({ insert: txInsert, update: txUpdate })); + return { txInsert, txUpdate }; +} + +function postReq(body: unknown) { + return new NextRequest('http://localhost/api/auth/accept-invite', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +const EMAIL_INVITE: Invite = { id: 'inv-1', email: 'invited@example.com', role: 'admin' }; + +describe('GET /api/auth/accept-invite', () => { + beforeEach(() => vi.clearAllMocks()); + + it('returns 400 when token is missing', async () => { + const res = await GET(new NextRequest('http://localhost/api/auth/accept-invite')); + expect(res.status).toBe(400); + }); + + it('returns valid invite details', async () => { + setup({ invite: EMAIL_INVITE }); + const res = await GET(new NextRequest('http://localhost/api/auth/accept-invite?token=abc')); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json).toMatchObject({ valid: true, email: 'invited@example.com', role: 'admin', emailLocked: true }); + }); + + it('returns 404 for an invalid/expired/used token', async () => { + setup({ invite: null }); + const res = await GET(new NextRequest('http://localhost/api/auth/accept-invite?token=gone')); + expect(res.status).toBe(404); + }); +}); + +describe('POST /api/auth/accept-invite', () => { + beforeEach(() => vi.clearAllMocks()); + + it('creates the user with the invite role and consumes the token', async () => { + const { txInsert, txUpdate } = setup({ invite: EMAIL_INVITE }); + const res = await POST(postReq({ token: 'abc', name: 'New Person', password: 'securepassword123' })); + expect(res.status).toBe(200); + expect(txInsert).toHaveBeenCalledOnce(); + const insertedValues = txInsert.mock.results[0].value.values.mock.calls[0][0]; + expect(insertedValues).toMatchObject({ email: 'invited@example.com', role: 'admin', name: 'New Person' }); + expect(insertedValues.emailVerified).toBeInstanceOf(Date); + expect(txUpdate).toHaveBeenCalledOnce(); // token consumed + }); + + it('rejects an invalid token (400)', async () => { + setup({ invite: null }); + const res = await POST(postReq({ token: 'gone', name: 'X', password: 'securepassword123' })); + expect(res.status).toBe(400); + }); + + it('rejects when the email already has an account (409)', async () => { + setup({ invite: EMAIL_INVITE, existingUser: true }); + const res = await POST(postReq({ token: 'abc', name: 'X', password: 'securepassword123' })); + expect(res.status).toBe(409); + }); + + it('requires an email for link-only invites (400)', async () => { + setup({ invite: { id: 'inv-2', email: null, role: 'learner' } }); + const res = await POST(postReq({ token: 'abc', name: 'X', password: 'securepassword123' })); + expect(res.status).toBe(400); + }); + + it('accepts a link-only invite when the invitee supplies an email', async () => { + const { txInsert } = setup({ invite: { id: 'inv-2', email: null, role: 'learner' } }); + const res = await POST(postReq({ token: 'abc', name: 'X', password: 'securepassword123', email: 'self@example.com' })); + expect(res.status).toBe(200); + const insertedValues = txInsert.mock.results[0].value.values.mock.calls[0][0]; + expect(insertedValues).toMatchObject({ email: 'self@example.com', role: 'learner' }); + }); + + it('rejects a short password (400)', async () => { + setup({ invite: EMAIL_INVITE }); + const res = await POST(postReq({ token: 'abc', name: 'X', password: 'short' })); + expect(res.status).toBe(400); + }); +}); diff --git a/src/app/api/auth/accept-invite/route.ts b/src/app/api/auth/accept-invite/route.ts new file mode 100644 index 0000000..a6bd9f3 --- /dev/null +++ b/src/app/api/auth/accept-invite/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import bcrypt from 'bcryptjs'; +import { and, eq, gt, isNull } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { invites, users } from '@/lib/db/schema'; + +// Look up a still-valid invite by token: not expired, not yet accepted. +async function findValidInvite(token: string) { + const now = new Date(); + const [row] = await db + .select({ id: invites.id, email: invites.email, role: invites.role }) + .from(invites) + .where(and(eq(invites.token, token), gt(invites.expires, now), isNull(invites.acceptedAt))) + .limit(1); + return row; +} + +// GET — validate a token so the accept page can show the target email / role. +export async function GET(req: NextRequest): Promise { + const token = req.nextUrl.searchParams.get('token') ?? ''; + if (!token) return NextResponse.json({ valid: false }, { status: 400 }); + const invite = await findValidInvite(token); + if (!invite) return NextResponse.json({ valid: false }, { status: 404 }); + return NextResponse.json({ valid: true, email: invite.email, role: invite.role, emailLocked: invite.email !== null }); +} + +const AcceptSchema = z.object({ + token: z.string().min(1), + name: z.string().min(1).max(255), + password: z.string().min(8).max(128), + email: z.string().email().optional(), +}); + +// POST — accept the invite: create the user (bypassing the signups gate), +// stamp the role from the invite, mark the email verified, consume the token. +export async function POST(req: NextRequest): Promise { + let body: unknown; + try { body = await req.json(); } catch { return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); } + + const parsed = AcceptSchema.safeParse(body); + if (!parsed.success) return NextResponse.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, { status: 400 }); + + const { token, name, password } = parsed.data; + const invite = await findValidInvite(token); + if (!invite) return NextResponse.json({ error: 'Invite is invalid, expired, or already used' }, { status: 400 }); + + // Email comes from the invite when present (email invite); otherwise the + // invitee supplies it (link-only invite). + const email = (invite.email ?? parsed.data.email)?.toLowerCase(); + if (!email) return NextResponse.json({ error: 'Email is required' }, { status: 400 }); + + const [existing] = await db.select({ id: users.id }).from(users).where(eq(users.email, email)).limit(1); + if (existing) return NextResponse.json({ error: 'A user with that email already exists' }, { status: 409 }); + + const passwordHash = await bcrypt.hash(password, 12); + const now = new Date(); + + await db.transaction(async (tx) => { + await tx.insert(users).values({ + email, + name, + passwordHash, + role: invite.role, + emailVerified: now, + }); + // Consume the token (idempotent guard against double-accept). + await tx.update(invites).set({ acceptedAt: now }).where(eq(invites.id, invite.id)); + }); + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/auth/migrate-session/__tests__/route.test.ts b/src/app/api/auth/migrate-session/__tests__/route.test.ts index 61fc977..1d7cc8c 100644 --- a/src/app/api/auth/migrate-session/__tests__/route.test.ts +++ b/src/app/api/auth/migrate-session/__tests__/route.test.ts @@ -1,7 +1,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { NextRequest } from 'next/server'; -const { mockTransaction } = vi.hoisted(() => ({ mockTransaction: vi.fn() })); +const { mockTransaction, mockExecute, mockDelete } = vi.hoisted(() => ({ + mockTransaction: vi.fn(), + mockExecute: vi.fn().mockResolvedValue([]), + mockDelete: vi.fn(), +})); vi.mock('@/lib/db', () => ({ db: { transaction: mockTransaction } })); @@ -44,6 +48,10 @@ function setupTransaction() { where: vi.fn().mockResolvedValue([]), }), }), + delete: mockDelete.mockReturnValue({ + where: vi.fn().mockResolvedValue([]), + }), + execute: mockExecute, }; return fn(tx); }); @@ -119,4 +127,31 @@ describe('POST /api/auth/migrate-session', () => { const res = await POST(makeRequest({ anonUserId: ANON_ID })); expect(res.status).toBe(200); }); + + it('mastery conflict — uses raw execute (INSERT...ON CONFLICT) and deletes anon rows', async () => { + await POST(makeRequest({ anonUserId: ANON_ID })); + // execute called for mastery merge + journey merge + journey transfer (3 calls) + expect(mockExecute).toHaveBeenCalled(); + // delete called to clean up anon mastery rows and residual journey rows + expect(mockDelete).toHaveBeenCalled(); + }); + + it('double-call is a no-op — second call with same anonId runs transaction over 0 rows', async () => { + await POST(makeRequest({ anonUserId: ANON_ID })); + await POST(makeRequest({ anonUserId: ANON_ID })); + // Both calls run the transaction; second one hits 0 rows — no error + expect(mockTransaction).toHaveBeenCalledTimes(2); + }); + + it('authz — body cannot supply authUserId; migration always targets session user', async () => { + const ATTACKER_ANON = '33333333-3333-3333-3333-333333333333'; + // Route schema only accepts anonUserId — any injected authUserId in body is ignored. + // Session is AUTH_ID; migration must succeed (200) and run the transaction. + const res = await POST(makeRequest({ anonUserId: ATTACKER_ANON })); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(true); + // Transaction ran — data moved to the session user, not any client-supplied target + expect(mockTransaction).toHaveBeenCalled(); + }); }); diff --git a/src/app/api/auth/migrate-session/route.ts b/src/app/api/auth/migrate-session/route.ts index 7420d5b..cf4620a 100644 --- a/src/app/api/auth/migrate-session/route.ts +++ b/src/app/api/auth/migrate-session/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; -import { eq } from 'drizzle-orm'; +import { eq, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { responses, mastery, journeyInstances } from '@/lib/db/schema'; import { getOptionalSession } from '@/lib/auth-helpers'; @@ -29,6 +29,7 @@ export async function POST(req: NextRequest): Promise { } const { anonUserId } = parsed.data; + // authUserId always comes from the server session — never trust the client-supplied value const authUserId = session.user.id; if (anonUserId === authUserId) { @@ -36,19 +37,44 @@ export async function POST(req: NextRequest): Promise { } await db.transaction(async (tx) => { + // Responses: simple reassign (no unique constraint on userId+checkpointId) await tx.update(responses).set({ userId: authUserId }).where(eq(responses.userId, anonUserId)); - await tx - .update(mastery) - .set({ userId: authUserId }) - .where(eq(mastery.userId, anonUserId)) - .catch(() => { - // Conflict if auth user already has mastery for same concept — skip - }); - await tx - .update(journeyInstances) - .set({ userId: authUserId }) - .where(eq(journeyInstances.userId, anonUserId)) - .catch(() => {}); + + // Mastery: on conflict keep higher score, more-recent lastSeen, earlier nextReview. + // Cannot use Drizzle's onConflictDoUpdate on an UPDATE, so use raw INSERT+SELECT. + await tx.execute(sql` + INSERT INTO mastery (user_id, concept_id, score, last_seen, next_review) + SELECT ${authUserId}, concept_id, score, last_seen, next_review + FROM mastery + WHERE user_id = ${anonUserId} + ON CONFLICT (user_id, concept_id) DO UPDATE + SET score = GREATEST(EXCLUDED.score, mastery.score), + last_seen = GREATEST(EXCLUDED.last_seen, mastery.last_seen), + next_review = LEAST(EXCLUDED.next_review, mastery.next_review) + `); + await tx.delete(mastery).where(eq(mastery.userId, anonUserId)); + + // JourneyInstances: merge by blueprint — take the higher position on conflict. + // First update auth user's existing journeys where anon is further ahead. + await tx.execute(sql` + UPDATE journey_instance AS auth + SET position = GREATEST(auth.position, anon.position) + FROM journey_instance AS anon + WHERE auth.user_id = ${authUserId} + AND anon.user_id = ${anonUserId} + AND auth.blueprint_id = anon.blueprint_id + `); + // Transfer journeys for blueprints the auth user hasn't started yet. + await tx.execute(sql` + UPDATE journey_instance + SET user_id = ${authUserId} + WHERE user_id = ${anonUserId} + AND blueprint_id NOT IN ( + SELECT blueprint_id FROM journey_instance WHERE user_id = ${authUserId} + ) + `); + // Remove any remaining anon rows (those whose blueprints were already merged above). + await tx.delete(journeyInstances).where(eq(journeyInstances.userId, anonUserId)); }); // Rename Redis budget key diff --git a/src/app/api/cron/review-digest/__tests__/route.test.ts b/src/app/api/cron/review-digest/__tests__/route.test.ts new file mode 100644 index 0000000..5c2f64e --- /dev/null +++ b/src/app/api/cron/review-digest/__tests__/route.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; + +vi.mock('@/lib/db/queries', () => ({ + getUsersWithDueReviews: vi.fn(), +})); + +vi.mock('@/lib/email', () => ({ + sendEmail: vi.fn(), +})); + +vi.mock('@/lib/db', () => ({ + db: { + select: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + }, +})); + +vi.mock('@/lib/db/schema', () => ({ + jobs: { id: 'id', idempotencyKey: 'ik', status: 'status', attempts: 'attempts' }, +})); + +import { GET } from '../route'; +import { getUsersWithDueReviews } from '@/lib/db/queries'; +import { sendEmail } from '@/lib/email'; +import { db } from '@/lib/db'; + +const mockGetUsers = vi.mocked(getUsersWithDueReviews); +const mockSendEmail = vi.mocked(sendEmail); +const mockDb = vi.mocked(db); + +function makeRequest(headers: Record = {}) { + return new NextRequest('http://localhost/api/cron/review-digest', { headers }); +} + +function makeSelectChain(result: unknown[]) { + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue(result) }), + }), + }; +} + +function makeInsertChain() { + return { + values: vi.fn().mockReturnValue({ + onConflictDoNothing: vi.fn().mockResolvedValue([]), + }), + }; +} + +function makeUpdateChain() { + return { + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([]), + }), + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + vi.stubEnv('REVIEW_DIGEST_ENABLED', 'true'); +}); + +describe('GET /api/cron/review-digest', () => { + it('skips when REVIEW_DIGEST_ENABLED is not set', async () => { + vi.unstubAllEnvs(); + const res = await GET(makeRequest()); + const body = await res.json(); + expect(body.skipped).toBe(true); + expect(mockSendEmail).not.toHaveBeenCalled(); + }); + + it('returns 401 when CRON_SECRET set but header missing', async () => { + vi.stubEnv('CRON_SECRET', 'secret123'); + const res = await GET(makeRequest()); + expect(res.status).toBe(401); + expect(mockSendEmail).not.toHaveBeenCalled(); + }); + + it('returns 200 with correct auth header', async () => { + vi.stubEnv('CRON_SECRET', 'secret123'); + mockGetUsers.mockResolvedValue([]); + const res = await GET(makeRequest({ Authorization: 'Bearer secret123' })); + expect(res.status).toBe(200); + }); + + it('sends email to each recipient and marks job done', async () => { + const recipient = { + userId: 'u-1', + email: 'alice@example.com', + name: 'Alice', + locale: 'en', + dueCount: 2, + topConcepts: ['A', 'B'], + }; + mockGetUsers.mockResolvedValue([recipient]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockDb.select as any).mockReturnValue(makeSelectChain([])); // no existing job + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockDb.insert as any).mockReturnValue(makeInsertChain()); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockDb.update as any).mockReturnValue(makeUpdateChain()); + mockSendEmail.mockResolvedValue(undefined); + + const res = await GET(makeRequest()); + const body = await res.json(); + expect(body.sent).toBe(1); + expect(body.skipped).toBe(0); + expect(mockSendEmail).toHaveBeenCalledOnce(); + expect(mockSendEmail).toHaveBeenCalledWith( + expect.objectContaining({ to: 'alice@example.com' }), + ); + }); + + it('skips recipient if job already done today (idempotency)', async () => { + const recipient = { + userId: 'u-2', + email: 'bob@example.com', + name: 'Bob', + locale: 'en', + dueCount: 1, + topConcepts: ['X'], + }; + mockGetUsers.mockResolvedValue([recipient]); + // Existing job with status 'done' + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockDb.select as any).mockReturnValue(makeSelectChain([{ id: 'job-1', status: 'done', attempts: 1 }])); + + const res = await GET(makeRequest()); + const body = await res.json(); + expect(body.sent).toBe(0); + expect(body.skipped).toBe(1); + expect(mockSendEmail).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/cron/review-digest/route.ts b/src/app/api/cron/review-digest/route.ts new file mode 100644 index 0000000..b370710 --- /dev/null +++ b/src/app/api/cron/review-digest/route.ts @@ -0,0 +1,94 @@ +import { createHmac } from 'crypto'; +import { NextRequest, NextResponse } from 'next/server'; +import { eq } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { jobs } from '@/lib/db/schema'; +import { getUsersWithDueReviews } from '@/lib/db/queries'; +import { reviewDigestEmail } from '@/lib/email/templates'; +import { sendEmail } from '@/lib/email'; + +const BASE_URL = process.env.AUTH_URL ?? 'http://localhost:3000'; + +export function signUserId(userId: string): string { + const secret = process.env.UNSUBSCRIBE_SECRET ?? 'dev-secret-change-in-prod'; + return createHmac('sha256', secret).update(userId).digest('hex'); +} + +export async function GET(request: NextRequest): Promise { + // Gate by env flag — feature is opt-in + if (process.env.REVIEW_DIGEST_ENABLED !== 'true') { + return NextResponse.json({ skipped: true, reason: 'REVIEW_DIGEST_ENABLED is not set' }); + } + + // Auth: Vercel Cron sends Authorization: Bearer + const cronSecret = process.env.CRON_SECRET; + if (cronSecret) { + const auth = request.headers.get('Authorization'); + if (auth !== `Bearer ${cronSecret}`) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + } + + const now = new Date(); + const dateKey = now.toISOString().slice(0, 10); // yyyy-mm-dd + const recipients = await getUsersWithDueReviews(now); + + let sent = 0; + let skipped = 0; + + for (const recipient of recipients) { + const idempotencyKey = `digest:${recipient.userId}:${dateKey}`; + + // Idempotency: skip if already sent today + const [existing] = await db + .select({ id: jobs.id, status: jobs.status, attempts: jobs.attempts }) + .from(jobs) + .where(eq(jobs.idempotencyKey, idempotencyKey)) + .limit(1); + + if (existing?.status === 'done') { + skipped++; + continue; + } + + // Insert or update job row + if (!existing) { + await db.insert(jobs).values({ + type: 'review-digest', + idempotencyKey, + status: 'running', + payloadJson: { userId: recipient.userId, date: dateKey }, + attempts: 1, + }).onConflictDoNothing(); + } else { + await db + .update(jobs) + .set({ status: 'running', attempts: (existing.attempts ?? 0) + 1 }) + .where(eq(jobs.id, existing.id)); + } + + const sig = signUserId(recipient.userId); + const unsubscribeUrl = `${BASE_URL}/api/unsubscribe?userId=${encodeURIComponent(recipient.userId)}&sig=${sig}`; + const reviewUrl = `${BASE_URL}/review`; + + const email = reviewDigestEmail({ + name: recipient.name, + dueCount: recipient.dueCount, + topConcepts: recipient.topConcepts, + reviewUrl, + unsubscribeUrl, + locale: recipient.locale, + }); + + await sendEmail({ to: recipient.email, ...email }); + + await db + .update(jobs) + .set({ status: 'done' }) + .where(eq(jobs.idempotencyKey, idempotencyKey)); + + sent++; + } + + return NextResponse.json({ sent, skipped, total: recipients.length, date: dateKey }); +} diff --git a/src/app/api/grade/__tests__/route.test.ts b/src/app/api/grade/__tests__/route.test.ts index 8f9fbb7..068108e 100644 --- a/src/app/api/grade/__tests__/route.test.ts +++ b/src/app/api/grade/__tests__/route.test.ts @@ -19,6 +19,7 @@ const VALID_BODY = { const MOCK_GRADE_RESULT = { responseId: 'resp-1', gradeId: 'grade-1', + referenceAnswer: 'A closure retains access to variables from its enclosing scope.', grade: { verdict: 'mastered' as const, rubric_hits: ['r1'], diff --git a/src/app/api/grade/route.ts b/src/app/api/grade/route.ts index 8c48e03..985e940 100644 --- a/src/app/api/grade/route.ts +++ b/src/app/api/grade/route.ts @@ -43,6 +43,8 @@ export async function POST(req: NextRequest): Promise { responseId: result.responseId, gradeId: result.gradeId, grade: result.grade, + newMasteryScore: result.newMasteryScore, + referenceAnswer: result.referenceAnswer, }); } catch (err) { if (err instanceof BudgetExceededError) { diff --git a/src/app/api/home-summary/__tests__/route.test.ts b/src/app/api/home-summary/__tests__/route.test.ts new file mode 100644 index 0000000..7c46252 --- /dev/null +++ b/src/app/api/home-summary/__tests__/route.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; + +vi.mock('@/lib/db/queries', () => ({ + getInProgressJourneys: vi.fn(), + getReviewCheckpoints: vi.fn(), + getMasteryMap: vi.fn(), +})); + +import { GET } from '../route'; +import { getInProgressJourneys, getReviewCheckpoints, getMasteryMap } from '@/lib/db/queries'; + +const mockGetInProgress = vi.mocked(getInProgressJourneys); +const mockGetReview = vi.mocked(getReviewCheckpoints); +const mockGetMastery = vi.mocked(getMasteryMap); + +const makeRequest = (userId?: string) => + new NextRequest(`http://localhost/api/home-summary${userId ? `?userId=${userId}` : ''}`); + +beforeEach(() => { + vi.clearAllMocks(); + mockGetInProgress.mockResolvedValue([]); + mockGetReview.mockResolvedValue([]); + mockGetMastery.mockResolvedValue([]); +}); + +describe('GET /api/home-summary', () => { + it('returns empty summary when no userId', async () => { + const res = await GET(makeRequest()); + const body = await res.json(); + expect(body).toEqual({ inProgress: [], dueReviewCount: 0, recentMastery: [] }); + expect(mockGetInProgress).not.toHaveBeenCalled(); + }); + + it('returns in-progress journeys for userId', async () => { + mockGetInProgress.mockResolvedValue([ + { blueprintId: 'bp1', intentKey: 'closures', title: 'Closures', position: 1, total: 3 }, + ]); + const res = await GET(makeRequest('user-abc')); + const body = await res.json(); + expect(body.inProgress).toHaveLength(1); + expect(body.inProgress[0].intentKey).toBe('closures'); + expect(body.inProgress[0].position).toBe(1); + }); + + it('counts due checkpoints as dueReviewCount', async () => { + mockGetReview.mockResolvedValue([ + { conceptId: 'c1', conceptName: 'A', score: 0.5, checkpointId: 'cp1', checkpointPrompt: 'p', checkpointKind: 'predict' }, + { conceptId: 'c2', conceptName: 'B', score: 0.4, checkpointId: 'cp2', checkpointPrompt: 'q', checkpointKind: 'explain' }, + ]); + const res = await GET(makeRequest('user-abc')); + const body = await res.json(); + expect(body.dueReviewCount).toBe(2); + }); + + it('returns top 3 most recently seen mastery entries', async () => { + const now = new Date(); + mockGetMastery.mockResolvedValue([ + { conceptId: 'c1', conceptName: 'A', blueprintId: 'bp1', blueprintTitle: 'T', blueprintIntentKey: 'k', score: 0.9, lastSeen: new Date(now.getTime() - 1000), nextReview: now }, + { conceptId: 'c2', conceptName: 'B', blueprintId: 'bp1', blueprintTitle: 'T', blueprintIntentKey: 'k', score: 0.5, lastSeen: new Date(now.getTime() - 3000), nextReview: now }, + { conceptId: 'c3', conceptName: 'C', blueprintId: 'bp1', blueprintTitle: 'T', blueprintIntentKey: 'k', score: 0.7, lastSeen: new Date(now.getTime() - 2000), nextReview: now }, + { conceptId: 'c4', conceptName: 'D', blueprintId: 'bp1', blueprintTitle: 'T', blueprintIntentKey: 'k', score: 0.3, lastSeen: new Date(now.getTime() - 5000), nextReview: now }, + ]); + const res = await GET(makeRequest('user-abc')); + const body = await res.json(); + expect(body.recentMastery).toHaveLength(3); + // Most recent first: A(−1s), C(−2s), B(−3s) + expect(body.recentMastery[0].conceptName).toBe('A'); + expect(body.recentMastery[1].conceptName).toBe('C'); + expect(body.recentMastery[2].conceptName).toBe('B'); + }); + + it('empty inProgress and zero dueReviewCount for first-time user', async () => { + const res = await GET(makeRequest('new-user')); + const body = await res.json(); + expect(body.inProgress).toEqual([]); + expect(body.dueReviewCount).toBe(0); + expect(body.recentMastery).toEqual([]); + }); +}); diff --git a/src/app/api/home-summary/route.ts b/src/app/api/home-summary/route.ts new file mode 100644 index 0000000..779e7de --- /dev/null +++ b/src/app/api/home-summary/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getInProgressJourneys, getReviewCheckpoints, getMasteryMap } from '@/lib/db/queries'; + +export interface HomeSummary { + inProgress: Array<{ + blueprintId: string; + intentKey: string; + title: string; + position: number; + total: number; + }>; + dueReviewCount: number; + recentMastery: Array<{ + conceptName: string; + blueprintTitle: string; + blueprintIntentKey: string; + score: number; + }>; +} + +export async function GET(request: NextRequest): Promise> { + const { searchParams } = new URL(request.url); + const userId = searchParams.get('userId'); + + if (!userId) { + return NextResponse.json({ inProgress: [], dueReviewCount: 0, recentMastery: [] }); + } + + const [inProgress, dueCheckpoints, masteryEntries] = await Promise.all([ + getInProgressJourneys(userId), + getReviewCheckpoints(userId), + getMasteryMap(userId), + ]); + + const recentMastery = [...masteryEntries] + .sort((a, b) => (b.lastSeen?.getTime() ?? 0) - (a.lastSeen?.getTime() ?? 0)) + .slice(0, 3) + .map(({ conceptName, blueprintTitle, blueprintIntentKey, score }) => ({ + conceptName, + blueprintTitle, + blueprintIntentKey, + score, + })); + + return NextResponse.json({ + inProgress, + dueReviewCount: dueCheckpoints.length, + recentMastery, + }); +} diff --git a/src/app/api/image/route.ts b/src/app/api/image/route.ts new file mode 100644 index 0000000..6479af9 --- /dev/null +++ b/src/app/api/image/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from 'next/server'; + +interface WikiPage { + title?: string; + thumbnail?: { source: string; width: number; height: number }; + content_urls?: { desktop?: { page?: string } }; +} + +interface WikiSearchResponse { + query?: { + pages?: Record; + }; +} + +export async function GET(req: NextRequest): Promise { + const q = req.nextUrl.searchParams.get('q')?.trim(); + if (!q) return NextResponse.json({ error: 'Missing ?q' }, { status: 400 }); + + try { + const url = new URL('https://en.wikipedia.org/w/api.php'); + url.searchParams.set('action', 'query'); + url.searchParams.set('format', 'json'); + url.searchParams.set('prop', 'pageimages|info'); + url.searchParams.set('pithumbsize', '800'); + url.searchParams.set('pilimit', '1'); + url.searchParams.set('inprop', 'url'); + url.searchParams.set('generator', 'search'); + url.searchParams.set('gsrsearch', q); + url.searchParams.set('gsrlimit', '3'); + + const res = await fetch(url.toString(), { + headers: { 'User-Agent': 'Curio/1.0 (educational app; contact anelissen@solem.fr)' }, + next: { revalidate: 86400 }, // cache 24 h + }); + + if (!res.ok) return NextResponse.json({ image: null }, { status: 200 }); + + const data = await res.json() as WikiSearchResponse; + const pages = data.query?.pages; + if (!pages) return NextResponse.json({ image: null }, { status: 200 }); + + // Find first page with a thumbnail + const hit = Object.values(pages).find((p) => p.thumbnail?.source); + if (!hit?.thumbnail) return NextResponse.json({ image: null }, { status: 200 }); + + const pageUrl = hit.content_urls?.desktop?.page ?? `https://en.wikipedia.org/wiki/${encodeURIComponent(hit.title ?? '')}`; + + return NextResponse.json( + { + image: { + src: hit.thumbnail.source, + width: hit.thumbnail.width, + height: hit.thumbnail.height, + alt: hit.title ?? q, + attribution: pageUrl, + attributionLabel: hit.title ?? q, + }, + }, + { + headers: { + 'Cache-Control': 'public, max-age=86400, stale-while-revalidate=3600', + }, + }, + ); + } catch { + return NextResponse.json({ image: null }, { status: 200 }); + } +} diff --git a/src/app/api/intent/route.ts b/src/app/api/intent/route.ts index 8c961b4..df0e30b 100644 --- a/src/app/api/intent/route.ts +++ b/src/app/api/intent/route.ts @@ -50,12 +50,13 @@ export async function POST(req: NextRequest): Promise { } const { input, userId } = parsed.data; + const locale = req.cookies.get('NEXT_LOCALE')?.value ?? 'en'; const { intentKey, blueprintId: existingBlueprintId, isNew } = await normalizeIntent(input); let blueprintId = existingBlueprintId; if (isNew) { - blueprintId = await generateBlueprint({ intentText: input, intentKey }); + blueprintId = await generateBlueprint({ intentText: input, intentKey, locale }); } let personalizationHint: string | undefined; diff --git a/src/app/api/lessons/__tests__/route.test.ts b/src/app/api/lessons/__tests__/route.test.ts index d304ca5..fa74b0c 100644 --- a/src/app/api/lessons/__tests__/route.test.ts +++ b/src/app/api/lessons/__tests__/route.test.ts @@ -67,6 +67,8 @@ const READY_RESPONSE = { }, ], }, + position: 0, + totalLessons: 1, }; const OUTLINE_RESPONSE = { @@ -77,6 +79,7 @@ const OUTLINE_RESPONSE = { concepts: [{ name: 'What is a closure?', ord: 0 }], estimatedMinutes: 10, }, + position: 0, }; function setupDbForOutline(existingJob = false) { @@ -134,7 +137,7 @@ describe('GET /api/lessons', () => { const body = await res.json(); expect(body.state).toBe('ready'); expect(body.lesson.segments).toHaveLength(1); - expect(mockSetCached).toHaveBeenCalledWith('javascript-closures', READY_RESPONSE); + expect(mockSetCached).toHaveBeenCalledWith('javascript-closures', 'en', READY_RESPONSE, 'standard', 'adult', 0); }); it('returns 200 outline and enqueues job on cold start', async () => { diff --git a/src/app/api/lessons/pdf/route.ts b/src/app/api/lessons/pdf/route.ts new file mode 100644 index 0000000..8eaf3ae --- /dev/null +++ b/src/app/api/lessons/pdf/route.ts @@ -0,0 +1,53 @@ +import 'server-only'; +import { NextRequest, NextResponse } from 'next/server'; +import { renderToBuffer } from '@react-pdf/renderer'; +import React from 'react'; +import { getLessonResponse } from '@/lib/db/queries'; +import { normalizeDepth } from '@/lib/generation/depth'; +import { LessonPDFDocument } from '@/lib/pdf/lesson-pdf'; + +export async function GET(req: NextRequest): Promise { + const { searchParams } = new URL(req.url); + const key = searchParams.get('key'); + const locale = searchParams.get('locale') ?? 'en'; + const depth = normalizeDepth(searchParams.get('depth') ?? undefined); + + if (!key) { + return NextResponse.json({ error: 'key required' }, { status: 400 }); + } + + const lesson = await getLessonResponse(key, undefined, locale, depth); + + if (!lesson || lesson.state !== 'ready') { + console.warn('[pdf] lesson not ready', { key, locale, depth, state: lesson?.state ?? 'null' }); + return NextResponse.json({ error: 'Lesson not ready', key, locale, depth, state: lesson?.state ?? null }, { status: 404 }); + } + + const data = { + blueprintTitle: lesson.blueprintTitle, + locale, + segments: lesson.lesson.segments.map((seg) => ({ + ord: seg.ord, + title: seg.title, + body: seg.body, + checkpoint: seg.checkpoint + ? { kind: seg.checkpoint.kind, prompt: seg.checkpoint.prompt } + : null, + })), + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const buffer = await renderToBuffer(React.createElement(LessonPDFDocument, { data }) as any); + + const slug = key.replace(/[^a-z0-9-]/gi, '-').toLowerCase(); + const filename = `curio-${slug}.pdf`; + + return new NextResponse(buffer as unknown as BodyInit, { + status: 200, + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Cache-Control': 'private, max-age=300', + }, + }); +} diff --git a/src/app/api/lessons/route.ts b/src/app/api/lessons/route.ts index c3c7913..c0aea0d 100644 --- a/src/app/api/lessons/route.ts +++ b/src/app/api/lessons/route.ts @@ -1,11 +1,19 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getLessonResponse } from '@/lib/db/queries'; +import { getLessonResponse, getUserPreferences } from '@/lib/db/queries'; import { getCachedLesson, setCachedLesson } from '@/lib/cache/lesson'; +import { normalizeDepth } from '@/lib/generation/depth'; import { enqueueGenerateLesson } from '@/lib/jobs/queue'; import { getColdStartRatelimit } from '@/lib/ratelimit'; import { eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { jobs } from '@/lib/db/schema'; +import { getOptionalSession } from '@/lib/auth-helpers'; +import { AGE_GROUPS, DEFAULT_PREFERENCES, type AgeGroup } from '@/schemas/preferences'; + +function normalizeAgeGroup(raw: string | null): AgeGroup { + if (raw && (AGE_GROUPS as readonly string[]).includes(raw)) return raw as AgeGroup; + return 'adult'; +} /** * GET /api/lessons?key= @@ -20,23 +28,46 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: 'Missing ?key parameter' }, { status: 400 }); } + const locale = req.cookies.get('NEXT_LOCALE')?.value ?? 'en'; + const depth = normalizeDepth(req.nextUrl.searchParams.get('depth')); + + // Resolve preferences — auth session takes priority, then query params, then defaults. + const session = await getOptionalSession(); + const uid = session?.user?.id ?? req.nextUrl.searchParams.get('uid') ?? undefined; + + let ageGroup: AgeGroup = DEFAULT_PREFERENCES.ageGroup; + let difficultyLevel: 1 | 2 | 3 = DEFAULT_PREFERENCES.defaultDifficulty; + + if (uid) { + try { + const prefs = await getUserPreferences(uid); + ageGroup = prefs.ageGroup; + difficultyLevel = prefs.defaultDifficulty; + } catch { + // non-fatal — defaults used + } + } else { + ageGroup = normalizeAgeGroup(req.nextUrl.searchParams.get('ageGroup')); + const rawDiff = Number(req.nextUrl.searchParams.get('difficulty')); + if (rawDiff === 1 || rawDiff === 2 || rawDiff === 3) difficultyLevel = rawDiff; + } + // 1. Redis cache - const cached = await getCachedLesson(key); + const cached = await getCachedLesson(key, locale, depth, ageGroup); if (cached?.state === 'ready') { return NextResponse.json(cached); } - // 2. DB — pass uid for difficulty variant selection (§13) - const uid = req.nextUrl.searchParams.get('uid') ?? undefined; - const result = await getLessonResponse(key, uid); + // 2. DB + const result = await getLessonResponse(key, uid, locale, depth, ageGroup); if (!result) { return NextResponse.json({ error: 'Blueprint not found' }, { status: 404 }); } - // 3. Ready — warm cache and return + // 3. Ready — warm cache and return (cache keyed by position so multi-lesson journeys are correct) if (result.state === 'ready') { - setCachedLesson(key, result).catch(() => {}); + setCachedLesson(key, locale, result, depth, ageGroup, result.position).catch(() => {}); return NextResponse.json(result); } @@ -50,7 +81,7 @@ export async function GET(req: NextRequest) { } } - const idempotencyKey = `generate-lesson:${result.blueprintId}:v1`; + const idempotencyKey = `generate-lesson:${result.blueprintId}:${locale}:${depth}:${ageGroup}:${difficultyLevel}:v1`; // Check if job already exists (pending or running) const [existingJob] = await db @@ -67,15 +98,19 @@ export async function GET(req: NextRequest) { type: 'generate-lesson', idempotencyKey, status: 'pending', - payloadJson: { blueprintId: result.blueprintId, intentKey: key }, + payloadJson: { blueprintId: result.blueprintId, intentKey: key, locale, depth, ageGroup, difficultyLevel }, }) .returning({ id: jobs.id }); - // Enqueue BullMQ job (uses jobId = idempotencyKey to dedup) + // Enqueue BullMQ job await enqueueGenerateLesson({ intentKey: key, blueprintId: result.blueprintId, idempotencyKey: jobRow.id, + locale, + depth, + ageGroup, + difficultyLevel, }); } diff --git a/src/app/api/lessons/stream/__tests__/route.test.ts b/src/app/api/lessons/stream/__tests__/route.test.ts new file mode 100644 index 0000000..2d874f7 --- /dev/null +++ b/src/app/api/lessons/stream/__tests__/route.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; + +vi.mock('@/lib/cache/stream', () => ({ + getStreamSegments: vi.fn(), + isStreamDone: vi.fn(), +})); + +import { GET } from '../route'; +import { getStreamSegments, isStreamDone } from '@/lib/cache/stream'; + +const mockGetSegments = vi.mocked(getStreamSegments); +const mockIsDone = vi.mocked(isStreamDone); + +function makeRequest(params: Record = {}) { + const qs = new URLSearchParams({ key: 'closures', locale: 'en', depth: 'standard', ageGroup: 'adult', position: '0', ...params }); + return new NextRequest(`http://localhost/api/lessons/stream?${qs}`); +} + +function parseSSEEvents(text: string): Array<{ event: string; data: string }> { + const events: Array<{ event: string; data: string }> = []; + const blocks = text.split('\n\n').filter(Boolean); + for (const block of blocks) { + const lines = block.split('\n'); + const eventLine = lines.find((l) => l.startsWith('event: ')); + const dataLine = lines.find((l) => l.startsWith('data: ')); + if (eventLine && dataLine) { + events.push({ + event: eventLine.replace('event: ', ''), + data: dataLine.replace('data: ', ''), + }); + } + } + return events; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('GET /api/lessons/stream', () => { + it('emits segment events then ready when segments exist and done', async () => { + const segments = [ + { ord: 0, title: 'Intro', body: 'Hello closures' }, + { ord: 1, title: 'Advanced', body: 'Deep dive' }, + ]; + // First poll: segments present, not done. Second poll: done. + mockGetSegments + .mockResolvedValueOnce(segments) + .mockResolvedValueOnce(segments); + mockIsDone + .mockResolvedValueOnce(false) // after first batch + .mockResolvedValueOnce(true); // after second check + + const res = await GET(makeRequest()); + expect(res.headers.get('Content-Type')).toContain('text/event-stream'); + + const reader = res.body!.getReader(); + const chunks: Uint8Array[] = []; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + const text = new TextDecoder().decode(Buffer.concat(chunks.map((c) => Buffer.from(c)))); + const events = parseSSEEvents(text); + + expect(events.some((e) => e.event === 'segment')).toBe(true); + expect(events.some((e) => e.event === 'ready')).toBe(true); + + // Segments emitted in order + const segEvents = events.filter((e) => e.event === 'segment'); + expect(segEvents).toHaveLength(2); + expect(JSON.parse(segEvents[0].data).title).toBe('Intro'); + expect(JSON.parse(segEvents[1].data).title).toBe('Advanced'); + }); + + it('emits ready immediately when buffer already done and segments ready', async () => { + const segments = [{ ord: 0, title: 'T', body: 'B' }]; + mockGetSegments.mockResolvedValue(segments); + mockIsDone.mockResolvedValue(true); + + const res = await GET(makeRequest()); + const reader = res.body!.getReader(); + const chunks: Uint8Array[] = []; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + const text = new TextDecoder().decode(Buffer.concat(chunks.map((c) => Buffer.from(c)))); + const events = parseSSEEvents(text); + expect(events.some((e) => e.event === 'ready')).toBe(true); + }); + + it('has correct SSE headers', async () => { + mockGetSegments.mockResolvedValue([]); + mockIsDone.mockResolvedValue(true); + + const res = await GET(makeRequest()); + expect(res.headers.get('Cache-Control')).toContain('no-cache'); + expect(res.headers.get('X-Accel-Buffering')).toBe('no'); + }); +}); diff --git a/src/app/api/lessons/stream/route.ts b/src/app/api/lessons/stream/route.ts new file mode 100644 index 0000000..3aaa335 --- /dev/null +++ b/src/app/api/lessons/stream/route.ts @@ -0,0 +1,80 @@ +import { type NextRequest } from 'next/server'; +import { getStreamSegments, isStreamDone } from '@/lib/cache/stream'; +import { DEFAULT_DEPTH, type LessonDepth } from '@/lib/generation/depth'; +import type { AgeGroup } from '@/schemas/preferences'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const POLL_INTERVAL_MS = 500; +const SEGMENT_EMIT_DELAY_MS = 300; // paced delivery between segments +const MAX_DURATION_MS = 28_000; // leave headroom before 30s Vercel limit + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +function sseEvent(event: string, data: string): string { + return `event: ${event}\ndata: ${data}\n\n`; +} + +export async function GET(request: NextRequest): Promise { + const { searchParams } = new URL(request.url); + const key = searchParams.get('key') ?? ''; + const locale = searchParams.get('locale') ?? 'en'; + const depth = (searchParams.get('depth') ?? DEFAULT_DEPTH) as LessonDepth; + const ageGroup = (searchParams.get('ageGroup') ?? 'adult') as AgeGroup; + const position = parseInt(searchParams.get('position') ?? '0', 10); + + const encoder = new TextEncoder(); + const abortSignal = request.signal; + + const stream = new ReadableStream({ + async start(controller) { + const enqueue = (event: string, data: string) => { + try { + controller.enqueue(encoder.encode(sseEvent(event, data))); + } catch { + // Controller may be closed if client disconnected + } + }; + + let emittedCount = 0; + const deadline = Date.now() + MAX_DURATION_MS; + + try { + while (Date.now() < deadline && !abortSignal.aborted) { + const segments = await getStreamSegments(key, locale, depth, ageGroup, position); + + // Emit newly-arrived segments one-by-one with pacing + while (emittedCount < segments.length && !abortSignal.aborted) { + enqueue('segment', JSON.stringify(segments[emittedCount])); + emittedCount++; + if (emittedCount < segments.length) { + await sleep(SEGMENT_EMIT_DELAY_MS); + } + } + + const done = await isStreamDone(key, locale, depth, ageGroup, position); + if (done && emittedCount >= segments.length) { + enqueue('ready', '{}'); + break; + } + + await sleep(POLL_INTERVAL_MS); + } + } catch { + // Swallow — client falls back to the 4s poll + } finally { + try { controller.close(); } catch { /* already closed */ } + } + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no', + }, + }); +} diff --git a/src/app/api/mastery/__tests__/route.test.ts b/src/app/api/mastery/__tests__/route.test.ts index cc68eaf..fdacaca 100644 --- a/src/app/api/mastery/__tests__/route.test.ts +++ b/src/app/api/mastery/__tests__/route.test.ts @@ -17,7 +17,9 @@ const ENTRIES = [ { conceptId: 'c1', conceptName: 'Closures', + blueprintId: 'bp1', blueprintTitle: 'JavaScript', + blueprintIntentKey: 'javascript', score: 0.8, lastSeen: NOW, nextReview: LATER, @@ -25,7 +27,9 @@ const ENTRIES = [ { conceptId: 'c2', conceptName: 'Promises', + blueprintId: 'bp1', blueprintTitle: 'JavaScript', + blueprintIntentKey: 'javascript', score: 0.45, lastSeen: NOW, nextReview: NOW, diff --git a/src/app/api/mastery/route.ts b/src/app/api/mastery/route.ts index ed0c993..9665e97 100644 --- a/src/app/api/mastery/route.ts +++ b/src/app/api/mastery/route.ts @@ -1,4 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; +import { eq, and, inArray } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { mastery, concepts, blueprints, journeyInstances } from '@/lib/db/schema'; import { getMasteryMap } from '@/lib/db/queries'; import { getOptionalSession } from '@/lib/auth-helpers'; @@ -16,14 +19,46 @@ export async function GET(req: NextRequest): Promise { const entries = await getMasteryMap(userId); - // Group by blueprintTitle for the map view - const grouped: Record = {}; + // Group by blueprintIntentKey for the map view + const grouped: Record = {}; for (const entry of entries) { - if (!grouped[entry.blueprintTitle]) { - grouped[entry.blueprintTitle] = { blueprintTitle: entry.blueprintTitle, concepts: [] }; + const key = entry.blueprintIntentKey; + if (!grouped[key]) { + grouped[key] = { blueprintId: entry.blueprintId, blueprintTitle: entry.blueprintTitle, blueprintIntentKey: key, concepts: [] }; } - grouped[entry.blueprintTitle].concepts.push(entry); + grouped[key].concepts.push(entry); } return NextResponse.json({ groups: Object.values(grouped) }); } + +/** + * DELETE /api/mastery?blueprintId=&userId= + * Removes all mastery records and the journey instance for a blueprint. + */ +export async function DELETE(req: NextRequest): Promise { + const session = await getOptionalSession(); + const userId = session?.user?.id ?? req.nextUrl.searchParams.get('userId'); + const blueprintId = req.nextUrl.searchParams.get('blueprintId'); + + if (!userId || !blueprintId) { + return NextResponse.json({ error: 'Missing userId or blueprintId' }, { status: 400 }); + } + + const conceptIds = ( + await db.select({ id: concepts.id }).from(concepts).where(eq(concepts.blueprintId, blueprintId)) + ).map((r) => r.id); + + await db.transaction(async (tx) => { + if (conceptIds.length > 0) { + await tx.delete(mastery).where( + and(eq(mastery.userId, userId), inArray(mastery.conceptId, conceptIds)), + ); + } + await tx.delete(journeyInstances).where( + and(eq(journeyInstances.userId, userId), eq(journeyInstances.blueprintId, blueprintId)), + ); + }); + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/preferences/route.ts b/src/app/api/preferences/route.ts new file mode 100644 index 0000000..f09035b --- /dev/null +++ b/src/app/api/preferences/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-helpers'; +import { getUserPreferences, setUserPreferences } from '@/lib/db/queries'; +import { UserPreferencesSchema } from '@/schemas/preferences'; + +export async function GET() { + const session = await requireAuth(); + const prefs = await getUserPreferences(session.user.id!); + return NextResponse.json(prefs); +} + +export async function PATCH(req: NextRequest) { + const session = await requireAuth(); + const body = await req.json(); + const parsed = UserPreferencesSchema.partial().safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: 'Invalid preferences', issues: parsed.error.issues }, { status: 400 }); + } + const updated = await setUserPreferences(session.user.id!, parsed.data); + return NextResponse.json(updated); +} diff --git a/src/app/api/review/__tests__/route.test.ts b/src/app/api/review/__tests__/route.test.ts index e083b0b..ef5fa49 100644 --- a/src/app/api/review/__tests__/route.test.ts +++ b/src/app/api/review/__tests__/route.test.ts @@ -3,6 +3,7 @@ import { NextRequest } from 'next/server'; vi.mock('@/lib/db/queries', () => ({ getReviewCheckpoints: vi.fn(), + getNextReviewAt: vi.fn().mockResolvedValue(null), })); import { GET } from '../route'; diff --git a/src/app/api/review/route.ts b/src/app/api/review/route.ts index a24b02b..b456e88 100644 --- a/src/app/api/review/route.ts +++ b/src/app/api/review/route.ts @@ -1,11 +1,13 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getReviewCheckpoints } from '@/lib/db/queries'; +import { getReviewCheckpoints, getNextReviewAt } from '@/lib/db/queries'; import { getOptionalSession } from '@/lib/auth-helpers'; /** * GET /api/review?userId= * * Returns due checkpoints for spaced-rep review, ordered by overdue-ness. + * When no reviews are due, includes `nextReviewAt` so the UI can tell the + * learner when to come back. */ export async function GET(req: NextRequest): Promise { const session = await getOptionalSession(); @@ -16,5 +18,11 @@ export async function GET(req: NextRequest): Promise { } const checkpoints = await getReviewCheckpoints(userId); + + if (checkpoints.length === 0) { + const nextReviewAt = await getNextReviewAt(userId); + return NextResponse.json({ checkpoints, nextReviewAt: nextReviewAt?.toISOString() ?? null }); + } + return NextResponse.json({ checkpoints }); } diff --git a/src/app/api/surprise/route.ts b/src/app/api/surprise/route.ts new file mode 100644 index 0000000..a89e96b --- /dev/null +++ b/src/app/api/surprise/route.ts @@ -0,0 +1,96 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { generateObject } from 'ai'; +import { z } from 'zod'; +import { prompts } from '@/lib/llm/prompts'; +import { llmClient } from '@/lib/llm/client'; +import { withSafety } from '@/lib/llm/safety'; +import { traceLLMCall } from '@/lib/observability'; +import { getColdStartRatelimit } from '@/lib/ratelimit'; + +const TopicSchema = z.object({ + topic: z + .string() + .min(8) + .max(300) + .describe('A natural-language learning intent phrased as the learner would type it.'), +}); + +const LOCALE_NAMES: Record = { + en: 'English', fr: 'French', es: 'Spanish', de: 'German', + pt: 'Portuguese', it: 'Italian', nl: 'Dutch', ja: 'Japanese', + zh: 'Chinese', ar: 'Arabic', ru: 'Russian', ko: 'Korean', +}; + +// Domain nudge diversifies suggestions across calls — the model otherwise +// gravitates to a handful of crowd-pleasers. +const DOMAINS = [ + 'physics', 'biology', 'economics', 'history', 'computer science', 'chemistry', + 'astronomy', 'linguistics', 'mathematics', 'neuroscience', 'music', 'geology', + 'everyday objects', 'the human body', 'engineering', 'art history', +]; + +/** + * GET /api/surprise — AI-chosen learning topic ("Spark something"). + * + * Returns a single fresh learning intent the client then feeds into the normal + * /api/intent flow. Rate-limited on the cold-start limiter (it spends a model call). + * All LLM access via llmClient.generator (invariant #1); prompt from registry (#2). + */ +export async function GET(req: NextRequest): Promise { + const limiter = getColdStartRatelimit(); + if (limiter) { + const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'anonymous'; + const { success } = await limiter.limit(ip); + if (!success) { + return NextResponse.json({ error: 'Too many requests' }, { status: 429 }); + } + } + + const locale = req.cookies.get('NEXT_LOCALE')?.value ?? 'en'; + const localeName = LOCALE_NAMES[locale] ?? 'English'; + const domain = DOMAINS[Math.floor(Math.random() * DOMAINS.length)]; + + const systemPrompt = withSafety(prompts.SUGGEST_TOPIC.template); + const userPrompt = `Suggest one learning intent. Lean toward ${domain} this time, but only if a genuinely intriguing question fits. Phrase the topic in ${localeName}.`; + + try { + const start = Date.now(); + type TopicResult = { object: { topic: string }; usage: { promptTokens?: number; completionTokens?: number } | undefined }; + let genResult!: TopicResult; + try { + genResult = await generateObject({ + model: llmClient.generator, + schema: TopicSchema, + system: systemPrompt, + prompt: userPrompt, + temperature: 1, + maxTokens: 512, + }); + } catch (genErr) { + const raw = (genErr as Record)?.text; + if (typeof raw === 'string') { + const { repairTruncatedJson } = await import('@/lib/llm/repair'); + const parsed = TopicSchema.parse(JSON.parse(repairTruncatedJson(raw))); + genResult = { object: parsed, usage: (genErr as Record)?.usage as TopicResult['usage'] }; + } else { + throw genErr; + } + } + const { object, usage } = genResult; + void traceLLMCall({ + name: 'suggest-topic', + role: 'generator', + model: process.env.LLM_GENERATOR_MODEL ?? 'unknown', + input: userPrompt, + output: object, + latencyMs: Date.now() - start, + usage: { promptTokens: usage?.promptTokens, completionTokens: usage?.completionTokens }, + metadata: { domain, locale }, + }); + + return NextResponse.json({ topic: object.topic }); + } catch (err) { + console.error('[GET /api/surprise]', err); + return NextResponse.json({ error: 'Failed to suggest a topic' }, { status: 500 }); + } +} diff --git a/src/app/api/themes/route.ts b/src/app/api/themes/route.ts new file mode 100644 index 0000000..b7ce1dd --- /dev/null +++ b/src/app/api/themes/route.ts @@ -0,0 +1,15 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getThemeMap } from '@/lib/db/queries'; +import { getOptionalSession } from '@/lib/auth-helpers'; + +/** + * GET /api/themes?userId= + * Returns all themes with the user's interacted blueprints per theme. + */ +export async function GET(req: NextRequest): Promise { + const session = await getOptionalSession(); + const userId = session?.user?.id ?? req.nextUrl.searchParams.get('userId') ?? ''; + + const themes = await getThemeMap(userId); + return NextResponse.json({ themes }); +} diff --git a/src/app/api/unsubscribe/route.ts b/src/app/api/unsubscribe/route.ts new file mode 100644 index 0000000..8dbd107 --- /dev/null +++ b/src/app/api/unsubscribe/route.ts @@ -0,0 +1,39 @@ +import { createHmac } from 'crypto'; +import { type NextRequest } from 'next/server'; +import { setUserPreferences } from '@/lib/db/queries'; + +function verifySignature(userId: string, sig: string): boolean { + const secret = process.env.UNSUBSCRIBE_SECRET ?? 'dev-secret-change-in-prod'; + const expected = createHmac('sha256', secret).update(userId).digest('hex'); + // Constant-time comparison to prevent timing attacks + if (expected.length !== sig.length) return false; + let diff = 0; + for (let i = 0; i < expected.length; i++) { + diff |= expected.charCodeAt(i) ^ sig.charCodeAt(i); + } + return diff === 0; +} + +export async function GET(request: NextRequest): Promise { + const { searchParams } = new URL(request.url); + const userId = searchParams.get('userId') ?? ''; + const sig = searchParams.get('sig') ?? ''; + + if (!userId || !verifySignature(userId, sig)) { + return new Response('Invalid or expired unsubscribe link.', { status: 400 }); + } + + await setUserPreferences(userId, { emailDigest: false }); + + const BASE_URL = process.env.AUTH_URL ?? 'http://localhost:3000'; + + return new Response( + `Unsubscribed + +

    Unsubscribed

    +

    You've been removed from Curio review digests. You can re-enable them in Settings.

    +
    `, + { headers: { 'Content-Type': 'text/html; charset=utf-8' } }, + ); +} diff --git a/src/app/api/user/locale/route.ts b/src/app/api/user/locale/route.ts new file mode 100644 index 0000000..5b73ed5 --- /dev/null +++ b/src/app/api/user/locale/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { eq } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { users } from '@/lib/db/schema'; +import { getOptionalSession } from '@/lib/auth-helpers'; +import { LOCALES, LOCALE_COOKIE } from '@/i18n/config'; + +const PatchSchema = z.object({ + locale: z.enum(LOCALES), +}); + +export async function GET(): Promise { + const session = await getOptionalSession(); + if (!session?.user?.id) return NextResponse.json({ locale: 'en' }); + + const [row] = await db.select({ locale: users.locale }).from(users).where(eq(users.id, session.user.id)).limit(1); + return NextResponse.json({ locale: row?.locale ?? 'en' }); +} + +export async function PATCH(req: NextRequest): Promise { + const session = await getOptionalSession(); + if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + let body: unknown; + try { body = await req.json(); } catch { return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); } + + const parsed = PatchSchema.safeParse(body); + if (!parsed.success) return NextResponse.json({ error: 'Invalid locale' }, { status: 400 }); + + const { locale } = parsed.data; + await db.update(users).set({ locale }).where(eq(users.id, session.user.id)); + + const res = NextResponse.json({ locale }); + res.cookies.set(LOCALE_COOKIE, locale, { path: '/', maxAge: 60 * 60 * 24 * 365, sameSite: 'lax' }); + return res; +} diff --git a/src/app/auth/accept-invite/page.tsx b/src/app/auth/accept-invite/page.tsx new file mode 100644 index 0000000..e136c2d --- /dev/null +++ b/src/app/auth/accept-invite/page.tsx @@ -0,0 +1,116 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useSearchParams, useRouter } from 'next/navigation'; +import { useTranslations } from 'next-intl'; +import Link from 'next/link'; + +type ValidState = + | { status: 'loading' } + | { status: 'invalid' } + | { status: 'valid'; email: string | null; role: string; emailLocked: boolean }; + +export default function AcceptInvitePage() { + const t = useTranslations('auth.acceptInvite'); + const params = useSearchParams(); + const token = params.get('token') ?? ''; + const router = useRouter(); + + const [valid, setValid] = useState({ status: 'loading' }); + const [email, setEmail] = useState(''); + const [name, setName] = useState(''); + const [password, setPassword] = useState(''); + const [confirm, setConfirm] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!token) { setValid({ status: 'invalid' }); return; } + let cancelled = false; + (async () => { + const res = await fetch(`/api/auth/accept-invite?token=${encodeURIComponent(token)}`); + if (cancelled) return; + if (!res.ok) { setValid({ status: 'invalid' }); return; } + const d = (await res.json()) as { valid: boolean; email: string | null; role: string; emailLocked: boolean }; + if (!d.valid) { setValid({ status: 'invalid' }); return; } + setValid({ status: 'valid', email: d.email, role: d.role, emailLocked: d.emailLocked }); + if (d.email) setEmail(d.email); + })(); + return () => { cancelled = true; }; + }, [token]); + + if (valid.status === 'loading') { + return

    ; + } + + if (valid.status === 'invalid') { + return ( +
    +

    {t('invalidLink')}

    + {t('goToLogin')} +
    + ); + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (password !== confirm) { setError(t('passwordMismatch')); return; } + setLoading(true); + setError(null); + const res = await fetch('/api/auth/accept-invite', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token, name, password, email: valid.emailLocked ? undefined : email }), + }); + setLoading(false); + if (res.ok) { + router.push('/auth/login'); + } else { + const d = (await res.json()) as { error?: string }; + setError(d.error ?? t('error')); + } + }; + + return ( +
    +

    {t('title')}

    +

    {t('subtitle')}

    +
    + + setEmail(e.target.value)} + required + readOnly={valid.emailLocked} + autoComplete="email" + style={{ ...s.input, ...(valid.emailLocked ? s.inputLocked : null) }} + /> + + setName(e.target.value)} required autoComplete="name" style={s.input} /> + + setPassword(e.target.value)} required minLength={8} autoComplete="new-password" style={s.input} /> + + setConfirm(e.target.value)} required autoComplete="new-password" style={s.input} /> + {error &&

    {error}

    } + +
    +
    + ); +} + +const s = { + page: { display: 'flex', flexDirection: 'column' as const, alignItems: 'center', minHeight: '100svh', padding: 'var(--space-8)', paddingTop: 'var(--space-16)', gap: 'var(--space-3)' }, + heading: { fontFamily: 'var(--font-display)', fontSize: '1.5rem', fontWeight: 500, color: 'var(--color-ink)' }, + subtitle: { fontFamily: 'var(--font-display)', fontSize: '0.9375rem', color: 'var(--color-ink-muted)', marginBottom: 'var(--space-3)' }, + form: { width: '100%', maxWidth: '26rem', display: 'flex', flexDirection: 'column' as const, gap: 'var(--space-2)' }, + label: { fontFamily: 'var(--font-display)', fontSize: '0.875rem', fontWeight: 500, color: 'var(--color-ink)', marginTop: 'var(--space-3)' }, + input: { padding: 'var(--space-3) var(--space-4)', background: 'var(--color-surface)', border: '1px solid var(--color-border)', borderRadius: '4px', fontFamily: 'var(--font-body)', fontSize: '1rem', color: 'var(--color-ink)', outline: 'none' }, + inputLocked: { background: 'var(--color-bg)', color: 'var(--color-ink-muted)', cursor: 'not-allowed' }, + error: { color: 'var(--color-misconception)', fontSize: '0.875rem', margin: 0 }, + btn: { marginTop: 'var(--space-4)', padding: 'var(--space-3) var(--space-6)', background: 'var(--color-accent)', color: 'var(--color-on-accent)', border: 'none', borderRadius: '4px', fontFamily: 'var(--font-display)', fontSize: '0.9375rem', fontWeight: 500, cursor: 'pointer' }, + link: { color: 'var(--color-accent)', fontFamily: 'var(--font-display)', fontSize: '0.875rem', textDecoration: 'none' }, +} as const; diff --git a/src/app/auth/error/page.tsx b/src/app/auth/error/page.tsx index a9d6217..b19c974 100644 --- a/src/app/auth/error/page.tsx +++ b/src/app/auth/error/page.tsx @@ -1,28 +1,19 @@ 'use client'; import { useSearchParams } from 'next/navigation'; +import { useTranslations } from 'next-intl'; import Link from 'next/link'; -const ERROR_MESSAGES: Record = { - Configuration: 'Server configuration error. Contact support.', - AccessDenied: 'Access denied.', - Verification: 'Verification link expired or already used. Request a new one.', - OAuthSignin: 'Could not connect to the OAuth provider. Try again.', - OAuthCallback: 'OAuth callback error. Try again.', - OAuthCreateAccount: 'Could not create account via OAuth.', - EmailCreateAccount: 'Could not create account with that email.', - Callback: 'Authentication callback error.', - OAuthAccountNotLinked: 'This email is already registered with a different sign-in method.', - EmailSignin: 'Email sign-in failed.', - CredentialsSignin: 'Invalid email or password.', - SessionRequired: 'You must be signed in to access that page.', - Default: 'An authentication error occurred.', -}; +const KNOWN_CODES = ['Configuration', 'AccessDenied', 'Verification'] as const; +type KnownCode = (typeof KNOWN_CODES)[number]; export default function AuthErrorPage() { + const t = useTranslations('auth.error'); const params = useSearchParams(); - const code = params.get('error') ?? 'Default'; - const message = ERROR_MESSAGES[code] ?? ERROR_MESSAGES.Default; + const code = params.get('error') ?? ''; + const message = KNOWN_CODES.includes(code as KnownCode) + ? t(code as KnownCode) + : t('default'); return (
    - Sign-in error + {t('title')}

    {message}

    - Back to sign in + {t('backToSignIn')}
    ); diff --git a/src/app/auth/forgot-password/page.tsx b/src/app/auth/forgot-password/page.tsx index 20f8c69..43c8afd 100644 --- a/src/app/auth/forgot-password/page.tsx +++ b/src/app/auth/forgot-password/page.tsx @@ -2,8 +2,10 @@ import { useState } from 'react'; import Link from 'next/link'; +import { useTranslations } from 'next-intl'; export default function ForgotPasswordPage() { + const t = useTranslations('auth.forgotPassword'); const [email, setEmail] = useState(''); const [loading, setLoading] = useState(false); const [sent, setSent] = useState(false); @@ -20,26 +22,24 @@ export default function ForgotPasswordPage() { }); setLoading(false); if (res.ok) setSent(true); - else setError('Something went wrong. Try again.'); + else setError(t('error')); }; if (sent) { return (
    -

    Check your inbox

    -

    - If an account exists for that email, we sent a reset link. It expires in 1 hour. -

    - Back to sign in +

    {t('sentTitle')}

    +

    {t('sentBody')}

    + {t('backToSignIn')}
    ); } return (
    -

    Reset password

    +

    {t('title')}

    - + {error &&

    {error}

    }
    - Back to sign in + {t('backToSignIn')}
    ); } @@ -67,6 +67,6 @@ const s = { label: { fontFamily: 'var(--font-display)', fontSize: '0.875rem', fontWeight: 500, color: 'var(--color-ink)', marginTop: 'var(--space-3)' }, input: { padding: 'var(--space-3) var(--space-4)', background: 'var(--color-surface)', border: '1px solid var(--color-border)', borderRadius: '4px', fontFamily: 'var(--font-body)', fontSize: '1rem', color: 'var(--color-ink)', outline: 'none' }, error: { color: 'var(--color-misconception)', fontSize: '0.875rem', margin: 0 }, - btn: { marginTop: 'var(--space-4)', padding: 'var(--space-3) var(--space-6)', background: 'var(--color-accent)', color: '#fff', border: 'none', borderRadius: '4px', fontFamily: 'var(--font-display)', fontSize: '0.9375rem', fontWeight: 500, cursor: 'pointer' }, + btn: { marginTop: 'var(--space-4)', padding: 'var(--space-3) var(--space-6)', background: 'var(--color-accent)', color: 'var(--color-on-accent)', border: 'none', borderRadius: '4px', fontFamily: 'var(--font-display)', fontSize: '0.9375rem', fontWeight: 500, cursor: 'pointer' }, link: { color: 'var(--color-accent)', fontFamily: 'var(--font-display)', fontSize: '0.875rem', textDecoration: 'none' }, } as const; diff --git a/src/app/auth/login/login-client.tsx b/src/app/auth/login/login-client.tsx index 0625172..ed68d37 100644 --- a/src/app/auth/login/login-client.tsx +++ b/src/app/auth/login/login-client.tsx @@ -4,6 +4,7 @@ import { useState } from 'react'; import { signIn } from 'next-auth/react'; import { useRouter, useSearchParams } from 'next/navigation'; import Link from 'next/link'; +import { useTranslations } from 'next-intl'; interface Props { hasOidc: boolean; @@ -13,6 +14,7 @@ interface Props { } export default function LoginClient({ hasOidc, oidcLabel, hasGoogle, hasGitHub }: Props) { + const t = useTranslations('auth.login'); const router = useRouter(); const params = useSearchParams(); const callbackUrl = params.get('callbackUrl') ?? '/'; @@ -33,7 +35,7 @@ export default function LoginClient({ hasOidc, oidcLabel, hasGoogle, hasGitHub } }); setLoading(false); if (res?.error) { - setError('Invalid email or password.'); + setError(t('error')); } else { await migrateThenRedirect(callbackUrl, router); } @@ -41,12 +43,12 @@ export default function LoginClient({ hasOidc, oidcLabel, hasGoogle, hasGitHub } return (
    -

    Sign in

    +

    {t('title')}

    {resetSuccess && ( -

    Password updated. Sign in with your new password.

    +

    {t('resetSuccess')}

    )}
    - + - + {error &&

    {error}

    } - Forgot password? + {t('forgotPassword')}
    - {(hasGoogle || hasGitHub || hasOidc) &&
    or
    } + {(hasGoogle || hasGitHub || hasOidc) &&
    {t('or')}
    }
    {hasGoogle && ( )} {hasGitHub && ( )} {hasOidc && ( )}

    - No account?{' '} - Register + {t('noAccount')}{' '} + {t('register')}

    ); @@ -115,6 +117,17 @@ async function migrateThenRedirect(callbackUrl: string, router: ReturnType { e.preventDefault(); if (password !== confirm) { - setError('Passwords do not match.'); + setError(t('passwordMismatch')); return; } setLoading(true); @@ -38,7 +40,7 @@ export default function RegisterPage() { const signInRes = await signIn('credentials', { email, password, redirect: false }); if (signInRes?.error) { - setError('Account created but sign-in failed. Try signing in manually.'); + setError(t('signInFailed')); setLoading(false); return; } @@ -57,14 +59,15 @@ export default function RegisterPage() { } } - router.push('/'); + router.refresh(); + router.push('/onboarding'); }; return (
    -

    Create account

    +

    {t('title')}

    - + - + - + - + {error &&

    {error}

    }

    - Already have an account?{' '} - Sign in + {t('hasAccount')}{' '} + {t('signIn')}

    ); @@ -169,7 +172,7 @@ const styles = { marginTop: 'var(--space-4)', padding: 'var(--space-3) var(--space-6)', background: 'var(--color-accent)', - color: '#fff', + color: 'var(--color-on-accent)', border: 'none', borderRadius: '4px', fontFamily: 'var(--font-display)', diff --git a/src/app/auth/reset-password/page.tsx b/src/app/auth/reset-password/page.tsx index b621a12..9a406f5 100644 --- a/src/app/auth/reset-password/page.tsx +++ b/src/app/auth/reset-password/page.tsx @@ -2,9 +2,11 @@ import { useState } from 'react'; import { useSearchParams, useRouter } from 'next/navigation'; +import { useTranslations } from 'next-intl'; import Link from 'next/link'; export default function ResetPasswordPage() { + const t = useTranslations('auth.resetPassword'); const params = useSearchParams(); const token = params.get('token') ?? ''; const router = useRouter(); @@ -16,15 +18,15 @@ export default function ResetPasswordPage() { if (!token) { return (
    -

    Invalid reset link.

    - Request a new one +

    {t('invalidLink')}

    + {t('requestNew')}
    ); } const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (password !== confirm) { setError('Passwords do not match.'); return; } + if (password !== confirm) { setError(t('passwordMismatch')); return; } setLoading(true); setError(null); const res = await fetch('/api/auth/reset-password', { @@ -37,21 +39,21 @@ export default function ResetPasswordPage() { router.push('/auth/login?reset=1'); } else { const d = (await res.json()) as { error?: string }; - setError(d.error ?? 'Failed to reset password.'); + setError(d.error ?? t('error')); } }; return (
    -

    Set new password

    +

    {t('title')}

    - + setPassword(e.target.value)} required minLength={8} autoComplete="new-password" style={s.input} /> - + setConfirm(e.target.value)} required autoComplete="new-password" style={s.input} /> {error &&

    {error}

    }
    @@ -65,6 +67,6 @@ const s = { label: { fontFamily: 'var(--font-display)', fontSize: '0.875rem', fontWeight: 500, color: 'var(--color-ink)', marginTop: 'var(--space-3)' }, input: { padding: 'var(--space-3) var(--space-4)', background: 'var(--color-surface)', border: '1px solid var(--color-border)', borderRadius: '4px', fontFamily: 'var(--font-body)', fontSize: '1rem', color: 'var(--color-ink)', outline: 'none' }, error: { color: 'var(--color-misconception)', fontSize: '0.875rem', margin: 0 }, - btn: { marginTop: 'var(--space-4)', padding: 'var(--space-3) var(--space-6)', background: 'var(--color-accent)', color: '#fff', border: 'none', borderRadius: '4px', fontFamily: 'var(--font-display)', fontSize: '0.9375rem', fontWeight: 500, cursor: 'pointer' }, + btn: { marginTop: 'var(--space-4)', padding: 'var(--space-3) var(--space-6)', background: 'var(--color-accent)', color: 'var(--color-on-accent)', border: 'none', borderRadius: '4px', fontFamily: 'var(--font-display)', fontSize: '0.9375rem', fontWeight: 500, cursor: 'pointer' }, link: { color: 'var(--color-accent)', fontFamily: 'var(--font-display)', fontSize: '0.875rem', textDecoration: 'none' }, } as const; diff --git a/src/app/auth/verify-email/page.tsx b/src/app/auth/verify-email/page.tsx index 23146c1..b3ef2af 100644 --- a/src/app/auth/verify-email/page.tsx +++ b/src/app/auth/verify-email/page.tsx @@ -1,4 +1,8 @@ -export default function VerifyEmailPage() { +import { getTranslations } from 'next-intl/server'; + +export default async function VerifyEmailPage() { + const t = await getTranslations('auth.verifyEmail'); + return (
    - Check your inbox + {t('title')}

    - We sent a verification link to your email. Click it to confirm your address and activate your account. + {t('body')}

    - Didn't get it? Check spam, or try registering again. + {t('subtext')}

    ); diff --git a/src/app/globals.css b/src/app/globals.css index 45bea69..8f7398b 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2,6 +2,7 @@ @import "../styles/tokens.css"; @import "../styles/reading.css"; @import "../styles/app.css"; +@import "../styles/settings.css"; @layer base { *, @@ -62,3 +63,4 @@ } } } + diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 80c8d90..a591f64 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,21 +1,26 @@ import type { Metadata } from 'next'; -import { Newsreader, Inter } from 'next/font/google'; +import { Libre_Caslon_Text, Inter } from 'next/font/google'; import { SessionProvider } from 'next-auth/react'; +import { NextIntlClientProvider } from 'next-intl'; +import { getLocale, getMessages } from 'next-intl/server'; import { redirect } from 'next/navigation'; import { headers } from 'next/headers'; import { auth } from '@/auth'; import { isAuthRequired } from '@/lib/site-config'; import { SiteHeader } from '@/components/site-header'; +import { MigrateOnAuth } from '@/components/migrate-on-auth'; import './globals.css'; // Set the persisted theme before first paint to avoid a flash. Inlined; no deps. const THEME_BOOTSTRAP = `(function(){try{var t=localStorage.getItem('curio-theme');if(t==='dark'||t==='light')document.documentElement.setAttribute('data-theme',t);}catch(e){}})();`; -const newsreader = Newsreader({ +// Libre Caslon Text ships 400, 400-italic, and 700. The UI uses upright 400 only +// (no italics anywhere), so load just that face. +const libreCaslon = Libre_Caslon_Text({ subsets: ['latin'], variable: '--font-body', - style: ['normal', 'italic'], - weight: ['300', '400', '500', '600'], + style: ['normal'], + weight: ['400'], display: 'swap', }); @@ -31,27 +36,34 @@ export const metadata: Metadata = { }; export default async function RootLayout({ children }: { children: React.ReactNode }) { - const [session, authRequired] = await Promise.all([auth(), isAuthRequired()]); + const [session, authRequired, locale, messages] = await Promise.all([ + auth(), + isAuthRequired(), + getLocale(), + getMessages(), + ]); if (authRequired && !session?.user) { const hdrs = await headers(); const pathname = hdrs.get('x-pathname') ?? '/'; - // Skip redirect loop for auth pages if (!pathname.startsWith('/auth/')) { redirect('/auth/login'); } } return ( - +