feat: full feature buildout — streaming, i18n, mastery map, admin, jobs

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 22:08:14 +02:00
parent 4e38b5a791
commit 5bf6013460
161 changed files with 27152 additions and 1161 deletions
+329
View File
@@ -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 `<html>`).
- `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 `<html>` 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 `<MasteryMap/>`. 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 `<fieldset>`/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.
</content>
</invoke>
+70
View File
@@ -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 13 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=<slug>` (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=<slug>`
- 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
@@ -0,0 +1 @@
ALTER TABLE "user" ADD COLUMN "locale" varchar(10) DEFAULT 'en' NOT NULL;
@@ -0,0 +1 @@
ALTER TABLE "lesson" ADD COLUMN "locale" varchar(10) DEFAULT 'en' NOT NULL;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "lesson" ADD COLUMN "locale" varchar(10) DEFAULT 'en' NOT NULL;
@@ -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;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "lesson" ADD COLUMN "depth" varchar(20) DEFAULT 'standard' NOT NULL;
@@ -0,0 +1 @@
ALTER TABLE "lesson" ADD COLUMN "depth" varchar(20) DEFAULT 'standard' NOT NULL;
@@ -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';
@@ -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;
+2
View File
@@ -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;
@@ -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
);
+30
View File
@@ -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")
);
@@ -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;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+84
View File
@@ -43,6 +43,90 @@
"when": 1782070177922, "when": 1782070177922,
"tag": "0005_site_email", "tag": "0005_site_email",
"breakpoints": true "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
} }
] ]
} }
+30
View File
@@ -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');
+58 -16
View File
@@ -6,13 +6,14 @@
* On failure: increments attempt count; sets status to 'failed' after processing. * On failure: increments attempt count; sets status to 'failed' after processing.
*/ */
import { Worker } from 'bullmq'; import { Worker } from 'bullmq';
import { eq } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
import { db } from '../src/lib/db'; import { db } from '../src/lib/db';
import { jobs, blueprints, concepts, lessons } from '../src/lib/db/schema'; import { jobs, blueprints, concepts, lessons } from '../src/lib/db/schema';
import { generateLesson } from '../src/lib/generation/generate-lesson'; import { generateLesson } from '../src/lib/generation/generate-lesson';
import { verifyLesson } from '../src/lib/verification/verify-content'; import { verifyLesson } from '../src/lib/verification/verify-content';
import { getLessonResponse } from '../src/lib/db/queries'; import { getLessonResponse } from '../src/lib/db/queries';
import { setCachedLesson } from '../src/lib/cache/lesson'; import { setCachedLesson } from '../src/lib/cache/lesson';
import { appendStreamSegment, markStreamDone } from '../src/lib/cache/stream';
import { enqueuePromoteBlueprint } from '../src/lib/jobs/queue'; import { enqueuePromoteBlueprint } from '../src/lib/jobs/queue';
import type { GenerateLessonJobData } from '../src/lib/jobs/queue'; import type { GenerateLessonJobData } from '../src/lib/jobs/queue';
@@ -23,7 +24,7 @@ const connection = {
const worker = new Worker<GenerateLessonJobData>( const worker = new Worker<GenerateLessonJobData>(
'generate-lesson', 'generate-lesson',
async (job) => { 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 // 1. Idempotency check — find the job row by idempotency key
const [jobRow] = await db const [jobRow] = await db
@@ -34,9 +35,9 @@ const worker = new Worker<GenerateLessonJobData>(
if (jobRow?.status === 'done') { if (jobRow?.status === 'done') {
// Already completed — warm cache if needed and return // 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') { 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; return;
} }
@@ -47,15 +48,31 @@ const worker = new Worker<GenerateLessonJobData>(
.set({ status: 'running', attempts: (jobRow?.attempts ?? 0) + 1 }) .set({ status: 'running', attempts: (jobRow?.attempts ?? 0) + 1 })
.where(eq(jobs.id, idempotencyKey)); .where(eq(jobs.id, idempotencyKey));
// 2. Check lesson doesn't already exist // 2. Check lesson at this ord doesn't already exist
const existing = await getLessonResponse(intentKey); const [existingLesson] = await db
if (existing?.state === 'ready') { .select({ id: lessons.id })
await setCachedLesson(intentKey, existing); .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)); await db.update(jobs).set({ status: 'done' }).where(eq(jobs.id, idempotencyKey));
return; return;
} }
// 3. Fetch blueprint + concepts // 3. Fetch blueprint + concepts at this ord
const [blueprint] = await db const [blueprint] = await db
.select() .select()
.from(blueprints) .from(blueprints)
@@ -64,32 +81,57 @@ const worker = new Worker<GenerateLessonJobData>(
if (!blueprint) throw new Error(`Blueprint not found: ${blueprintId}`); 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 const conceptRows = await db
.select({ id: concepts.id, name: concepts.name, ord: concepts.ord, retrievalCtxRef: concepts.retrievalCtxRef }) .select({ id: concepts.id, name: concepts.name, ord: concepts.ord, retrievalCtxRef: concepts.retrievalCtxRef })
.from(concepts) .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({ const { lessonId } = await generateLesson({
blueprintId, blueprintId,
lessonOrd: 0, lessonOrd: ord,
topicTitle: blueprint.title, topicTitle: blueprint.title,
conceptsToGenerate: conceptRows.map((c) => ({ conceptsToGenerate: conceptRows.map((c) => ({
id: c.id, id: c.id,
name: c.name, name: c.name,
retrievalCtxRef: c.retrievalCtxRef, 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) // 5. T1 verification (non-blocking for serve — T2 + promotion handled by promote-blueprint job)
await verifyLesson({ lessonId, runT2: false }); await verifyLesson({ lessonId, runT2: false });
// 6. Write to Redis cache // 6. Write to Redis cache (position-keyed)
const lesson = await getLessonResponse(intentKey); 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') { 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 // 7. Update job ledger
+3 -1
View File
@@ -1,13 +1,15 @@
import generateLessonWorker from './generate-lesson-job'; import generateLessonWorker from './generate-lesson-job';
import promoteBlueprintWorker from './promote-blueprint-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) { async function shutdown(signal: string) {
console.log(`[workers] ${signal} received — draining and closing workers...`); console.log(`[workers] ${signal} received — draining and closing workers...`);
await Promise.all([ await Promise.all([
generateLessonWorker.close(), generateLessonWorker.close(),
promoteBlueprintWorker.close(), promoteBlueprintWorker.close(),
regenerateBlueprintWorker.close(),
]); ]);
console.log('[workers] Shutdown complete.'); console.log('[workers] Shutdown complete.');
process.exit(0); process.exit(0);
+1 -1
View File
@@ -100,7 +100,7 @@ const worker = new Worker<PromoteBlueprintJobData>(
} }
// 6. Invalidate cache so next request fetches fresh lesson with updated verify status // 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)); await db.update(jobs).set({ status: 'done' }).where(eq(jobs.id, idempotencyKey));
}, },
+165
View File
@@ -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<RegenerateBlueprintJobData>(
'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<string, { locale: string; depth: string; ageGroup: string }>();
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;
+5
View File
@@ -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 = {};
+586
View File
@@ -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 (812)",
"teen": "Teen (1317)",
"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 (812)",
"teen": "Teen (1317)",
"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"
}
}
}
+586
View File
@@ -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 daccepter cette invitation.",
"invalidLink": "Ce lien dinvitation 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 (812 ans)",
"teen": "Adolescent (1317 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 (812 ans)",
"teen": "Adolescent (1317 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"
}
}
}
+7 -2
View File
@@ -1,5 +1,10 @@
import type { NextConfig } from 'next'; 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);
+10 -1
View File
@@ -19,7 +19,7 @@
"test:golden": "tsx --env-file=.env.local tests/golden/run-grading-eval.ts", "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: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", "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": { "dependencies": {
"@ai-sdk/amazon-bedrock": "^4.0.119", "@ai-sdk/amazon-bedrock": "^4.0.119",
@@ -33,6 +33,7 @@
"@ai-sdk/provider": "^1.0.0", "@ai-sdk/provider": "^1.0.0",
"@ai-sdk/xai": "^3.0.96", "@ai-sdk/xai": "^3.0.96",
"@auth/drizzle-adapter": "^1.11.2", "@auth/drizzle-adapter": "^1.11.2",
"@react-pdf/renderer": "^4.5.1",
"@upstash/ratelimit": "^2.0.8", "@upstash/ratelimit": "^2.0.8",
"@upstash/redis": "^1.38.0", "@upstash/redis": "^1.38.0",
"ai": "^4.3.0", "ai": "^4.3.0",
@@ -43,18 +44,26 @@
"langfuse": "^3.0.0", "langfuse": "^3.0.0",
"next": "^15.3.0", "next": "^15.3.0",
"next-auth": "5.0.0-beta.31", "next-auth": "5.0.0-beta.31",
"next-intl": "^4.13.0",
"nodemailer": "^9.0.1", "nodemailer": "^9.0.1",
"ollama-ai-provider": "^1.2.0", "ollama-ai-provider": "^1.2.0",
"pg": "^8.13.0", "pg": "^8.13.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^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", "server-only": "^0.0.1",
"unified": "^11.0.5",
"zod": "^3.24.0" "zod": "^3.24.0"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.49.0", "@playwright/test": "^1.49.0",
"@tailwindcss/postcss": "^4.1.0", "@tailwindcss/postcss": "^4.1.0",
"@types/bcryptjs": "^3.0.0", "@types/bcryptjs": "^3.0.0",
"@types/mdast": "^4.0.4",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/nodemailer": "^8.0.1", "@types/nodemailer": "^8.0.1",
"@types/pg": "^8.11.0", "@types/pg": "^8.11.0",
+1752 -2
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1,4 +1,6 @@
allowBuilds: allowBuilds:
'@parcel/watcher': true
'@swc/core': true
esbuild: true esbuild: true
msgpackr-extract: true msgpackr-extract: true
sharp: true sharp: true
@@ -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<VerifyStatus, { fg: string; bg: string }> = {
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 <span style={{ ...s.badge, color: c.fg, background: c.bg }}>{status}</span>;
}
export default function BlueprintDetailClient({ detail }: { detail: BlueprintDetail }) {
const router = useRouter();
const { blueprint, concepts, lessons } = detail;
const [busy, setBusy] = useState<string | null>(null);
const [msg, setMsg] = useState<string | null>(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 (
<div>
<Link href="/admin/blueprints" style={s.back}> Blueprints</Link>
<div style={s.header}>
<h1 style={s.heading}>{blueprint.title}</h1>
<StatusBadge status={blueprint.status === 'published' ? 'pass' : blueprint.status === 'flagged' ? 'fail' : 'pending'} />
</div>
<p style={s.meta}>
<code>{blueprint.intentKey}</code> · content v{blueprint.contentVersion} · model {blueprint.modelVersion} · prompt v{blueprint.promptVersion} · status {blueprint.status}
{blueprint.staleAt && <> · <span style={{ color: 'var(--color-misconception)' }}> stale since {new Date(blueprint.staleAt).toLocaleDateString()}</span></>}
</p>
<button
disabled={busy === '__blueprint__'}
onClick={() => void forceRegenerate()}
style={s.flagBtn}
>
{busy === '__blueprint__' ? 'Enqueueing…' : 'Force Regenerate'}
</button>
{concepts.length > 0 && (
<section style={s.section}>
<h2 style={s.h2}>Concepts</h2>
<ol style={s.conceptList}>
{concepts.map((c) => <li key={c.id} style={s.conceptItem}>{c.name}</li>)}
</ol>
</section>
)}
{msg && <p role="status" style={s.toast}>{msg}</p>}
{lessons.length === 0 && <p style={s.empty}>No lessons generated yet for this blueprint.</p>}
{lessons.map((lesson) => (
<section key={lesson.id} style={s.lesson}>
<div style={s.lessonHead}>
<h2 style={s.h2}>Lesson {lesson.ord + 1} <span style={s.lessonMeta}>· ~{lesson.estMinutes} min · {lesson.locale}</span></h2>
<div style={s.actionRow}>
<button disabled={busy === lesson.id} onClick={() => reverify(lesson.id, false)} style={s.btn}>
{busy === lesson.id ? 'Verifying…' : 'Re-verify (T1)'}
</button>
<button disabled={busy === lesson.id} onClick={() => reverify(lesson.id, true)} style={s.btn}>
Re-verify (T2)
</button>
</div>
</div>
{lesson.segments.length === 0 && <p style={s.empty}>No segments.</p>}
{lesson.segments.map((seg) => (
<div key={seg.id} style={s.segment}>
<div style={s.segHead}>
<span style={s.segLabel}>Segment {seg.ord + 1} · difficulty {seg.difficultyLevel}</span>
<div style={s.actionRow}>
<StatusBadge status={seg.verifyStatus} />
{seg.verifyStatus !== 'fail' ? (
<button disabled={busy === seg.id} onClick={() => setContentStatus('segment', seg.id, 'fail')} style={s.flagBtn}>Flag</button>
) : (
<button disabled={busy === seg.id} onClick={() => setContentStatus('segment', seg.id, 'pending')} style={s.btn}>Clear flag</button>
)}
</div>
</div>
<p style={s.segText}>{seg.text || <em style={{ color: 'var(--color-ink-faint)' }}>empty segment body</em>}</p>
{seg.checkpoints.map((cp) => (
<div key={cp.id} style={s.checkpoint}>
<div style={s.segHead}>
<span style={s.cpKind}>{cp.kind}</span>
<div style={s.actionRow}>
<StatusBadge status={cp.verifyStatus} />
{cp.verifyStatus !== 'fail' ? (
<button disabled={busy === cp.id} onClick={() => setContentStatus('checkpoint', cp.id, 'fail')} style={s.flagBtn}>Flag</button>
) : (
<button disabled={busy === cp.id} onClick={() => setContentStatus('checkpoint', cp.id, 'pending')} style={s.btn}>Clear flag</button>
)}
</div>
</div>
<p style={s.cpPrompt}>{cp.prompt}</p>
<details style={s.details}>
<summary style={s.summary}>Reference answer &amp; rubric</summary>
<p style={s.refAnswer}>{cp.referenceAnswer}</p>
<pre style={s.rubric}>{JSON.stringify(cp.rubric, null, 2)}</pre>
</details>
</div>
))}
</div>
))}
</section>
))}
</div>
);
}
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;
+101
View File
@@ -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 <BlueprintDetailClient detail={detail} />;
}
+102 -4
View File
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useState } from 'react'; import { useState } from 'react';
type Blueprint = { id: string; intentKey: string; title: string; status: 'draft' | 'verifying' | 'published' | 'flagged'; createdAt: Date }; 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[] }) { export default function AdminBlueprintsClient({ blueprints }: { blueprints: Blueprint[] }) {
const router = useRouter(); const router = useRouter();
const [busy, setBusy] = useState<string | null>(null); const [busy, setBusy] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(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) => { const setStatus = async (id: string, status: Status) => {
setBusy(id); setBusy(id);
@@ -23,13 +41,76 @@ export default function AdminBlueprintsClient({ blueprints }: { blueprints: Blue
router.refresh(); 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 ( return (
<div> <div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-6)' }}>
<h1 style={s.heading}>Blueprints</h1> <h1 style={s.heading}>Blueprints</h1>
<div style={{ display: 'flex', gap: 'var(--space-3)', alignItems: 'center' }}>
{selected.size > 0 && (
<button
onClick={deleteSelected}
disabled={deleting}
style={{ ...s.btn, ...s.btnDanger }}
>
{deleting ? 'Deleting…' : `Delete ${selected.size} selected`}
</button>
)}
{blueprints.length > 0 && (
<button
onClick={deleteAll}
disabled={deleting}
style={{ ...s.btn, ...s.btnDangerOutline }}
>
Delete all
</button>
)}
</div>
</div>
{blueprints.length === 0 ? (
<p style={s.empty}>No blueprints yet.</p>
) : (
<div style={{ overflowX: 'auto' }}> <div style={{ overflowX: 'auto' }}>
<table style={s.table}> <table style={s.table}>
<thead> <thead>
<tr> <tr>
<th style={{ ...s.th, width: '2.5rem' }}>
<input
type="checkbox"
checked={allChecked}
ref={el => { if (el) el.indeterminate = someChecked; }}
onChange={toggleAll}
aria-label="Select all blueprints"
/>
</th>
{['Title', 'Intent key', 'Status', 'Created', 'Set status'].map((h) => ( {['Title', 'Intent key', 'Status', 'Created', 'Set status'].map((h) => (
<th key={h} style={s.th}>{h}</th> <th key={h} style={s.th}>{h}</th>
))} ))}
@@ -37,8 +118,18 @@ export default function AdminBlueprintsClient({ blueprints }: { blueprints: Blue
</thead> </thead>
<tbody> <tbody>
{blueprints.map((bp) => ( {blueprints.map((bp) => (
<tr key={bp.id}> <tr key={bp.id} style={selected.has(bp.id) ? s.rowSelected : undefined}>
<td style={s.td}>{bp.title}</td> <td style={s.td}>
<input
type="checkbox"
checked={selected.has(bp.id)}
onChange={() => toggleOne(bp.id)}
aria-label={`Select ${bp.title}`}
/>
</td>
<td style={s.td}>
<Link href={`/admin/blueprints/${bp.id}`} style={s.titleLink}>{bp.title}</Link>
</td>
<td style={s.td}><code style={{ fontSize: '0.8125rem' }}>{bp.intentKey}</code></td> <td style={s.td}><code style={{ fontSize: '0.8125rem' }}>{bp.intentKey}</code></td>
<td style={s.td}>{bp.status}</td> <td style={s.td}>{bp.status}</td>
<td style={s.td}>{new Date(bp.createdAt).toLocaleDateString()}</td> <td style={s.td}>{new Date(bp.createdAt).toLocaleDateString()}</td>
@@ -57,14 +148,21 @@ export default function AdminBlueprintsClient({ blueprints }: { blueprints: Blue
</tbody> </tbody>
</table> </table>
</div> </div>
)}
</div> </div>
); );
} }
const s = { 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' }, 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 }, 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' }, 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; } as const;
+186
View File
@@ -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<string | null>(null);
const [lastLink, setLastLink] = useState<string | null>(null);
const [copied, setCopied] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(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 (
<div>
<h1 style={s.heading}>Invites</h1>
<form onSubmit={create} style={s.form}>
<div style={s.field}>
<label style={s.label} htmlFor="invite-email">Email (optional leave blank for a shareable link)</label>
<input
id="invite-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="person@example.com"
style={s.input}
/>
</div>
<div style={s.field}>
<label style={s.label} htmlFor="invite-role">Role</label>
<select id="invite-role" value={role} onChange={(e) => setRole(e.target.value as 'learner' | 'admin')} style={s.select}>
<option value="learner">learner</option>
<option value="admin">admin</option>
</select>
</div>
<button type="submit" disabled={creating} style={s.createBtn}>
{creating ? 'Creating…' : 'Create invite'}
</button>
</form>
{error && <p role="alert" style={s.error}>{error}</p>}
{lastLink && (
<div style={s.linkBox}>
<p style={s.linkLabel}>Invite link {email ? '' : '(share this — no email was sent)'}:</p>
<div style={s.linkRow}>
<code style={s.linkCode}>{lastLink}</code>
<button onClick={() => copy(lastLink, 'last')} style={s.btn}>{copied === 'last' ? 'Copied' : 'Copy'}</button>
</div>
</div>
)}
<div style={{ overflowX: 'auto', marginTop: 'var(--space-6)' }}>
<table style={s.table}>
<thead>
<tr>
{['Email', 'Role', 'Status', 'Created', 'Actions'].map((h) => <th key={h} style={s.th}>{h}</th>)}
</tr>
</thead>
<tbody>
{invites.length === 0 && (
<tr><td style={s.td} colSpan={5}><span style={s.muted}>No invites yet.</span></td></tr>
)}
{invites.map((inv) => {
const status = statusOf(inv);
return (
<tr key={inv.id}>
<td style={s.td}>{inv.email ?? <span style={s.muted}>link only</span>}</td>
<td style={s.td}>{inv.role}</td>
<td style={s.td}><span style={{ ...s.badge, ...badgeStyle(status) }}>{status}</span></td>
<td style={s.td}>{new Date(inv.createdAt).toLocaleDateString()}</td>
<td style={{ ...s.td, display: 'flex', gap: 'var(--space-2)', flexWrap: 'wrap' as const }}>
{status === 'pending' && (
<>
<button onClick={() => copy(inviteUrl(inv.token), inv.id)} style={s.btn}>
{copied === inv.id ? 'Copied' : 'Copy link'}
</button>
<button disabled={busy === inv.id} onClick={() => revoke(inv.id)} style={s.dangerBtn}>Revoke</button>
</>
)}
{status !== 'pending' && (
<button disabled={busy === inv.id} onClick={() => revoke(inv.id)} style={s.dangerBtn}>Delete</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
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;
+24
View File
@@ -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 <AdminInvitesClient invites={rows} />;
}
+3
View File
@@ -13,8 +13,11 @@ export default async function AdminLayout({ children }: { children: ReactNode })
{[ {[
{ href: '/admin', label: 'Overview' }, { href: '/admin', label: 'Overview' },
{ href: '/admin/users', label: 'Users' }, { href: '/admin/users', label: 'Users' },
{ href: '/admin/invites', label: 'Invites' },
{ href: '/admin/reports', label: 'Reports' }, { href: '/admin/reports', label: 'Reports' },
{ href: '/admin/blueprints', label: 'Blueprints' }, { href: '/admin/blueprints', label: 'Blueprints' },
{ href: '/admin/misconceptions', label: 'Misconceptions' },
{ href: '/admin/quality', label: 'Quality' },
{ href: '/admin/settings', label: 'Settings' }, { href: '/admin/settings', label: 'Settings' },
].map(({ href, label }) => ( ].map(({ href, label }) => (
<li key={href}> <li key={href}>
@@ -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 <span style={s.responseText}>{text}</span>;
}
const idx = text.indexOf(span);
return (
<span style={s.responseText}>
{text.slice(0, idx)}
<mark style={s.evidenceMark}>{span}</mark>
{text.slice(idx + span.length)}
</span>
);
}
function QueueCard({ item, onProcessed }: { item: NovelQueueItem; onProcessed: () => void }) {
const [busy, setBusy] = useState(false);
const [expanded, setExpanded] = useState(false);
const [form, setForm] = useState<LabelFormState>({
tag: '',
signature: item.evidenceSpan,
description: '',
correction: '',
addGolden: false,
});
const [msg, setMsg] = useState<string | null>(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 (
<div style={s.card}>
<div style={s.cardHead}>
<div>
<span style={s.conceptTag}>{item.conceptName ?? 'Unknown concept'}</span>
<span style={s.timestamp}>{new Date(item.createdAt).toLocaleDateString()}</span>
</div>
<div style={s.actionRow}>
<button style={s.labelBtn} disabled={busy} onClick={() => setExpanded((e) => !e)}>
{expanded ? 'Collapse' : 'Label'}
</button>
<button style={s.dismissBtn} disabled={busy} onClick={() => void dismiss()}>
{busy ? '…' : 'Dismiss'}
</button>
</div>
</div>
<p style={s.checkpointPrompt}>{item.checkpointPrompt}</p>
<div style={s.responsePre}>
<EvidenceHighlight text={item.responseText} span={item.evidenceSpan} />
</div>
{item.existingMisconceptions.length > 0 && (
<details style={s.details}>
<summary style={s.summary}>Existing misconceptions for this concept ({item.existingMisconceptions.length})</summary>
<ul style={s.miscList}>
{item.existingMisconceptions.map((m) => (
<li key={m.id} style={s.miscItem}>
<code>{m.tag}</code> {m.signature}
</li>
))}
</ul>
</details>
)}
{expanded && (
<div style={s.labelForm}>
<label style={s.fieldLabel}>
Tag (short slug, e.g. <code>scope-confusion</code>)
<input
style={s.input}
value={form.tag}
onChange={(e) => setForm((f) => ({ ...f, tag: e.target.value }))}
placeholder="e.g. scope-confusion"
/>
</label>
<label style={s.fieldLabel}>
Signature (text pattern that reveals this)
<input
style={s.input}
value={form.signature}
onChange={(e) => setForm((f) => ({ ...f, signature: e.target.value }))}
/>
</label>
<label style={s.fieldLabel}>
Description (what the learner wrongly believes)
<input
style={s.input}
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
placeholder="Optional"
/>
</label>
<label style={s.fieldLabel}>
Correction (brief guidance for the tutor)
<input
style={s.input}
value={form.correction}
onChange={(e) => setForm((f) => ({ ...f, correction: e.target.value }))}
placeholder="Optional"
/>
</label>
<label style={{ ...s.fieldLabel, flexDirection: 'row', alignItems: 'center', gap: 'var(--space-2)' }}>
<input
type="checkbox"
checked={form.addGolden}
onChange={(e) => setForm((f) => ({ ...f, addGolden: e.target.checked }))}
/>
Also add as golden eval case
</label>
{msg && <p style={s.error}>{msg}</p>}
<button
style={s.saveBtn}
disabled={busy || !form.tag.trim() || !form.signature.trim()}
onClick={() => void label()}
>
{busy ? 'Saving…' : 'Save misconception'}
</button>
</div>
)}
</div>
);
}
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 (
<div>
<h1 style={s.heading}>Novel Misconceptions Queue</h1>
<p style={s.sub}>
{items.length === 0
? 'Queue is empty — all novel responses have been reviewed.'
: `${items.length} unprocessed response${items.length !== 1 ? 's' : ''} — label or dismiss each.`}
</p>
{items.map((item) => (
<QueueCard key={item.id} item={item} onProcessed={() => markProcessed(item.id)} />
))}
</div>
);
}
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;
+9
View File
@@ -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 <MisconceptionsClient queue={queue} />;
}
+202
View File
@@ -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 (
<span style={{
fontFamily: 'var(--font-display)',
fontSize: '0.75rem',
fontWeight: 600,
padding: '2px 8px',
borderRadius: '999px',
background: ok ? 'var(--color-mastered-subtle)' : 'var(--color-misconception-subtle)',
color: ok ? 'var(--color-mastered)' : 'var(--color-misconception)',
}}>
{ok ? 'PASS' : 'FAIL'}
</span>
);
}
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 (
<div>
<h1 style={s.heading}>Quality Dashboard</h1>
<p style={s.sub}>False-fail rate, verdict distribution, and high-dispute checkpoints.</p>
{/* ── Latest golden eval ────────────────────────────────────────────── */}
<section style={s.section}>
<h2 style={s.h2}>Golden Eval Latest Grading Run</h2>
{!latestRun ? (
<p style={s.empty}>No eval runs persisted yet. Run <code>PERSIST_EVAL_RUNS=1 pnpm test:golden</code>.</p>
) : (
<>
<div style={s.metric}>
<span style={s.metricLabel}>False-fail rate</span>
<span style={s.metricValue}>{pct(latestRun.falseFailRate ?? 0)}</span>
<span style={s.threshold}>threshold: {pct(FALSE_FAIL_THRESHOLD)}</span>
{badge(latestRun.falseFailRate ?? 0, FALSE_FAIL_THRESHOLD)}
</div>
<div style={s.metric}>
<span style={s.metricLabel}>Verdict accuracy</span>
<span style={s.metricValue}>{pct(latestRun.accuracy ?? 0)}</span>
<span style={s.threshold}>floor: 70%</span>
{badge(1 - (latestRun.accuracy ?? 0), 0.30)}
</div>
<div style={s.metric}>
<span style={s.metricLabel}>Cases / Passed</span>
<span style={s.metricValue}>{latestRun.passed} / {latestRun.total}</span>
</div>
<div style={s.metric}>
<span style={s.metricLabel}>Model</span>
<span style={s.metricValue}>{latestRun.modelVersion}</span>
</div>
<div style={s.metric}>
<span style={s.metricLabel}>Run date</span>
<span style={s.metricValue}>{latestRun.createdAt.toLocaleDateString()}</span>
</div>
</>
)}
</section>
{/* ── Trend table ───────────────────────────────────────────────────── */}
{gradingTrend.length > 1 && (
<section style={s.section}>
<h2 style={s.h2}>Grading Eval History</h2>
<div style={{ overflowX: 'auto' }}>
<table style={s.table}>
<thead>
<tr>
{['Date', 'False-fail', 'Accuracy', 'Total', 'Model'].map((h) => (
<th key={h} style={s.th}>{h}</th>
))}
</tr>
</thead>
<tbody>
{gradingTrend.map((run) => (
<tr key={run.id}>
<td style={s.td}>{run.createdAt.toLocaleDateString()}</td>
<td style={{ ...s.td, color: (run.falseFailRate ?? 0) > FALSE_FAIL_THRESHOLD ? 'var(--color-misconception)' : 'var(--color-mastered)' }}>
{pct(run.falseFailRate ?? 0)}
</td>
<td style={s.td}>{pct(run.accuracy ?? 0)}</td>
<td style={s.td}>{run.total}</td>
<td style={s.td}>{run.modelVersion}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
{/* ── Online metrics ────────────────────────────────────────────────── */}
<section style={s.section}>
<h2 style={s.h2}>Production Metrics (last 30 days)</h2>
<div style={s.metric}>
<span style={s.metricLabel}>Total grades</span>
<span style={s.metricValue}>{onlineMetrics.totalGrades.toLocaleString()}</span>
</div>
<div style={s.metric}>
<span style={s.metricLabel}>Grade-wrong dispute rate</span>
<span style={s.metricValue}>{pct(onlineMetrics.gradeWrongRate)}</span>
<span style={s.threshold}>({onlineMetrics.gradeWrongReports} reports)</span>
</div>
<div style={s.metric}>
<span style={s.metricLabel}>Auto-flagged checkpoints</span>
<span style={s.metricValue}>{onlineMetrics.autoFlaggedCheckpoints}</span>
</div>
{Object.entries(onlineMetrics.byVerdict).length > 0 && (
<div style={{ marginTop: 'var(--space-4)' }}>
<p style={{ ...s.metricLabel, marginBottom: 'var(--space-2)' }}>Verdict distribution</p>
<div style={{ overflowX: 'auto' }}>
<table style={s.table}>
<thead>
<tr>
{['Verdict', 'Count', 'Share'].map((h) => <th key={h} style={s.th}>{h}</th>)}
</tr>
</thead>
<tbody>
{Object.entries(onlineMetrics.byVerdict)
.sort(([, a], [, b]) => b - a)
.map(([verdict, count]) => (
<tr key={verdict}>
<td style={s.td}>{verdict}</td>
<td style={s.td}>{count}</td>
<td style={s.td}>
{onlineMetrics.totalGrades > 0 ? pct(count / onlineMetrics.totalGrades) : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</section>
{/* ── High-dispute checkpoints ──────────────────────────────────────── */}
<section style={s.section}>
<h2 style={s.h2}>High-Dispute Checkpoints</h2>
{highDispute.length === 0 ? (
<p style={s.empty}>No disputed checkpoints. Good.</p>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={s.table}>
<thead>
<tr>
{['Blueprint', 'Checkpoint prompt', 'Disputes'].map((h) => <th key={h} style={s.th}>{h}</th>)}
</tr>
</thead>
<tbody>
{highDispute.map((cp) => (
<tr key={cp.checkpointId}>
<td style={s.td}>
{cp.blueprintId ? (
<Link href={`/admin/blueprints/${cp.blueprintId}`} style={s.link}>{cp.blueprintTitle}</Link>
) : '—'}
</td>
<td style={{ ...s.td, maxWidth: '40ch', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{cp.prompt}
</td>
<td style={s.td}>{cp.disputeCount}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
);
}
+21 -3
View File
@@ -1,15 +1,33 @@
import { requireAdmin } from '@/lib/auth-helpers'; import { requireAdmin } from '@/lib/auth-helpers';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { contentReports } from '@/lib/db/schema'; import { contentReports, checkpoints, segments, lessons, blueprints } from '@/lib/db/schema';
import { asc } from 'drizzle-orm'; import { asc, eq } from 'drizzle-orm';
import AdminReportsClient from './reports-client'; import AdminReportsClient from './reports-client';
export default async function AdminReportsPage() { export default async function AdminReportsPage() {
await requireAdmin(); 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 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) .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)); .orderBy(asc(contentReports.createdAt));
return <AdminReportsClient reports={rows} />; return <AdminReportsClient reports={rows} />;
+25 -4
View File
@@ -1,9 +1,22 @@
'use client'; 'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState } from 'react'; 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[] }) { export default function AdminReportsClient({ reports }: { reports: Report[] }) {
const router = useRouter(); const router = useRouter();
@@ -34,9 +47,15 @@ export default function AdminReportsClient({ reports }: { reports: Report[] }) {
<span style={s.date}>{new Date(r.createdAt).toLocaleDateString()}</span> <span style={s.date}>{new Date(r.createdAt).toLocaleDateString()}</span>
</div> </div>
{r.notes && <p style={s.notes}>{r.notes}</p>} {r.notes && <p style={s.notes}>{r.notes}</p>}
<p style={{ fontSize: '0.8125rem', color: 'var(--color-ink-faint)', fontFamily: 'var(--font-display)' }}> <Link
checkpoint {r.checkpointId.slice(0, 8)} href={`/learn/${r.intentKey}?depth=${r.depth}`}
</p> target="_blank"
rel="noopener noreferrer"
style={s.lessonLink}
>
{r.blueprintTitle} · segment {r.segmentOrd + 1}
</Link>
<p style={s.checkpointMeta}>checkpoint {r.checkpointId.slice(0, 8)}</p>
<div style={{ display: 'flex', gap: 'var(--space-2)', marginTop: 'var(--space-3)' }}> <div style={{ display: 'flex', gap: 'var(--space-2)', marginTop: 'var(--space-3)' }}>
<button disabled={busy === r.id} onClick={() => act(r.id, 'dismiss')} style={s.btn}>Dismiss</button> <button disabled={busy === r.id} onClick={() => act(r.id, 'dismiss')} style={s.btn}>Dismiss</button>
<button disabled={busy === r.id} onClick={() => act(r.id, 're-verify')} style={s.btn}>Flag for re-verify</button> <button disabled={busy === r.id} onClick={() => act(r.id, 're-verify')} style={s.btn}>Flag for re-verify</button>
@@ -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' }, 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)' }, date: { fontSize: '0.8125rem', color: 'var(--color-ink-faint)', fontFamily: 'var(--font-display)' },
notes: { fontSize: '0.9375rem', color: 'var(--color-ink-muted)' }, 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)' }, 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; } as const;
+1 -1
View File
@@ -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)' }, 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 }, 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)' }, 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; } as const;
+2 -2
View File
@@ -5,12 +5,12 @@ import { asc } from 'drizzle-orm';
import AdminUsersClient from './users-client'; import AdminUsersClient from './users-client';
export default async function AdminUsersPage() { export default async function AdminUsersPage() {
await requireAdmin(); const session = await requireAdmin();
const rows = await db const rows = await db
.select({ id: users.id, name: users.name, email: users.email, role: users.role, createdAt: users.createdAt }) .select({ id: users.id, name: users.name, email: users.email, role: users.role, createdAt: users.createdAt })
.from(users) .from(users)
.orderBy(asc(users.createdAt)); .orderBy(asc(users.createdAt));
return <AdminUsersClient users={rows} />; return <AdminUsersClient users={rows} selfId={session.user.id} />;
} }
+13 -1
View File
@@ -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 }; 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 router = useRouter();
const [busy, setBusy] = useState<string | null>(null); const [busy, setBusy] = useState<string | null>(null);
@@ -48,11 +48,20 @@ export default function AdminUsersClient({ users }: { users: UserRow[] }) {
<td style={s.td}>{u.role}</td> <td style={s.td}>{u.role}</td>
<td style={s.td}>{new Date(u.createdAt).toLocaleDateString()}</td> <td style={s.td}>{new Date(u.createdAt).toLocaleDateString()}</td>
<td style={{ ...s.td, display: 'flex', gap: 'var(--space-2)', flexWrap: 'wrap' as const }}> <td style={{ ...s.td, display: 'flex', gap: 'var(--space-2)', flexWrap: 'wrap' as const }}>
{u.id === selfId ? (
<span style={s.selfNote}>You</span>
) : (
<>
{u.role !== 'admin' && ( {u.role !== 'admin' && (
<button disabled={busy === u.id} onClick={() => action(u.id, { role: 'admin' })} style={s.btn}> <button disabled={busy === u.id} onClick={() => action(u.id, { role: 'admin' })} style={s.btn}>
Make admin Make admin
</button> </button>
)} )}
{u.role === 'admin' && (
<button disabled={busy === u.id} onClick={() => action(u.id, { role: 'learner' })} style={s.btn}>
Make learner
</button>
)}
{u.role !== 'suspended' && ( {u.role !== 'suspended' && (
<button disabled={busy === u.id} onClick={() => action(u.id, { role: 'suspended' })} style={s.btn}> <button disabled={busy === u.id} onClick={() => action(u.id, { role: 'suspended' })} style={s.btn}>
Suspend Suspend
@@ -66,6 +75,8 @@ export default function AdminUsersClient({ users }: { users: UserRow[] }) {
<button disabled={busy === u.id} onClick={() => del(u.id)} style={s.dangerBtn}> <button disabled={busy === u.id} onClick={() => del(u.id)} style={s.dangerBtn}>
Delete Delete
</button> </button>
</>
)}
</td> </td>
</tr> </tr>
))} ))}
@@ -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)' }, 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' }, 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' }, 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; } as const;
+60 -1
View File
@@ -2,13 +2,19 @@ import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod'; import { z } from 'zod';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { db } from '@/lib/db'; 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 { requireAdmin } from '@/lib/auth-helpers';
import { markBlueprintStale } from '@/lib/generation/staleness';
import { enqueueRegenerateBlueprint } from '@/lib/jobs/queue';
const PatchSchema = z.object({ const PatchSchema = z.object({
status: z.enum(['draft', 'verifying', 'published', 'flagged']).optional(), 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<NextResponse> { export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }): Promise<NextResponse> {
await requireAdmin(); await requireAdmin();
const { id } = await params; 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)); await db.update(blueprints).set(parsed.data).where(eq(blueprints.id, id));
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }): Promise<NextResponse> {
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 });
}
@@ -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<NextResponse> {
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 });
}
}
+28 -1
View File
@@ -1,8 +1,15 @@
import { NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { asc } from 'drizzle-orm'; import { asc } from 'drizzle-orm';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { blueprints } from '@/lib/db/schema'; import { blueprints } from '@/lib/db/schema';
import { requireAdmin } from '@/lib/auth-helpers'; 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<NextResponse> { export async function GET(): Promise<NextResponse> {
await requireAdmin(); await requireAdmin();
@@ -12,3 +19,23 @@ export async function GET(): Promise<NextResponse> {
.orderBy(asc(blueprints.createdAt)); .orderBy(asc(blueprints.createdAt));
return NextResponse.json({ blueprints: rows }); return NextResponse.json({ blueprints: rows });
} }
export async function DELETE(req: NextRequest): Promise<NextResponse> {
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 });
}
+33
View File
@@ -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<NextResponse> {
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 });
}
+13
View File
@@ -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<NextResponse> {
await requireAdmin();
const { id } = await params;
await db.delete(invites).where(eq(invites.id, id));
return NextResponse.json({ ok: true });
}
@@ -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);
});
});
+69
View File
@@ -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<NextResponse> {
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<NextResponse> {
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 });
}
+72
View File
@@ -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<NextResponse> {
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 });
}
@@ -96,6 +96,14 @@ describe('PATCH /api/admin/users/[id]', () => {
}); });
expect(res.status).toBe(200); 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]', () => { describe('DELETE /api/admin/users/[id]', () => {
@@ -150,4 +158,12 @@ describe('DELETE /api/admin/users/[id]', () => {
}) })
).rejects.toThrow('DB error'); ).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();
});
}); });
+11 -2
View File
@@ -10,9 +10,14 @@ const PatchSchema = z.object({
}); });
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }): Promise<NextResponse> { export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }): Promise<NextResponse> {
await requireAdmin(); const session = await requireAdmin();
const { id } = await params; 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; let body: unknown;
try { body = await req.json(); } catch { return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); } 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<NextResponse> { export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }): Promise<NextResponse> {
await requireAdmin(); const session = await requireAdmin();
const { id } = await params; 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 db.transaction(async (tx) => {
await tx.delete(responses).where(eq(responses.userId, id)); await tx.delete(responses).where(eq(responses.userId, id));
await tx.delete(mastery).where(eq(mastery.userId, id)); await tx.delete(mastery).where(eq(mastery.userId, id));
+60 -1
View File
@@ -5,10 +5,41 @@ vi.mock('@/lib/db/queries', () => ({
advanceJourneyPosition: vi.fn(), 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 { POST } from '../route';
import { advanceJourneyPosition } from '@/lib/db/queries'; import { advanceJourneyPosition } from '@/lib/db/queries';
import { db } from '@/lib/db';
const mockAdvance = vi.mocked(advanceJourneyPosition); const mockAdvance = vi.mocked(advanceJourneyPosition);
const mockDb = vi.mocked(db);
const VALID_BODY = { const VALID_BODY = {
userId: '123e4567-e89b-12d3-a456-426614174000', userId: '123e4567-e89b-12d3-a456-426614174000',
@@ -23,6 +54,14 @@ function makeRequest(body: unknown) {
}); });
} }
function mockSelectChain(results: Record<string, unknown>[]) {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(results),
}),
} as unknown as ReturnType<typeof mockDb.select>);
}
describe('POST /api/advance', () => { describe('POST /api/advance', () => {
beforeEach(() => vi.clearAllMocks()); beforeEach(() => vi.clearAllMocks());
@@ -57,16 +96,36 @@ describe('POST /api/advance', () => {
expect(res.status).toBe(400); 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); 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<typeof mockDb.select>);
const res = await POST(makeRequest(VALID_BODY)); const res = await POST(makeRequest(VALID_BODY));
expect(res.status).toBe(200); expect(res.status).toBe(200);
const body = await res.json(); const body = await res.json();
expect(body.position).toBe(2); 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 () => { it('calls advanceJourneyPosition with correct args', async () => {
mockAdvance.mockResolvedValue(1); mockAdvance.mockResolvedValue(1);
mockSelectChain([{ lessonCount: 1 }]);
await POST(makeRequest(VALID_BODY)); await POST(makeRequest(VALID_BODY));
expect(mockAdvance).toHaveBeenCalledWith(VALID_BODY.userId, VALID_BODY.blueprintId); expect(mockAdvance).toHaveBeenCalledWith(VALID_BODY.userId, VALID_BODY.blueprintId);
}); });
+80 -4
View File
@@ -1,11 +1,19 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod'; 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 { advanceJourneyPosition } from '@/lib/db/queries';
import { getOptionalSession } from '@/lib/auth-helpers'; import { getOptionalSession } from '@/lib/auth-helpers';
import { enqueueGenerateLesson } from '@/lib/jobs/queue';
const AdvanceBodySchema = z.object({ const AdvanceBodySchema = z.object({
userId: z.string().uuid().optional(), userId: z.string().uuid().optional(),
blueprintId: z.string().uuid(), 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. * Advances the learner's journey position for a given blueprint.
* Called when all checkpoints in a lesson are mastered. * Called when all checkpoints in a lesson are mastered.
* Creates the journey instance on first call (anonymous-session-first). * 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) { export async function POST(req: NextRequest) {
let body: unknown; let body: unknown;
@@ -25,7 +39,7 @@ export async function POST(req: NextRequest) {
const parsed = AdvanceBodySchema.safeParse(body); const parsed = AdvanceBodySchema.safeParse(body);
if (!parsed.success) { 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(); const session = await getOptionalSession();
@@ -33,8 +47,70 @@ export async function POST(req: NextRequest) {
if (!userId) { if (!userId) {
return NextResponse.json({ error: 'Missing userId or blueprintId' }, { status: 400 }); return NextResponse.json({ error: 'Missing userId or blueprintId' }, { status: 400 });
} }
const { blueprintId } = parsed.data;
const position = await advanceJourneyPosition(userId, blueprintId); const { blueprintId, locale = 'en', depth = 'standard', ageGroup = 'adult', intentKey } = parsed.data;
return NextResponse.json({ position });
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' });
} }
@@ -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<string, unknown> | 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<unknown>) => 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);
});
});
+72
View File
@@ -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<NextResponse> {
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<NextResponse> {
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 });
}
@@ -1,7 +1,11 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server'; 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 } })); vi.mock('@/lib/db', () => ({ db: { transaction: mockTransaction } }));
@@ -44,6 +48,10 @@ function setupTransaction() {
where: vi.fn().mockResolvedValue([]), where: vi.fn().mockResolvedValue([]),
}), }),
}), }),
delete: mockDelete.mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
execute: mockExecute,
}; };
return fn(tx); return fn(tx);
}); });
@@ -119,4 +127,31 @@ describe('POST /api/auth/migrate-session', () => {
const res = await POST(makeRequest({ anonUserId: ANON_ID })); const res = await POST(makeRequest({ anonUserId: ANON_ID }));
expect(res.status).toBe(200); 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();
});
}); });
+39 -13
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod'; import { z } from 'zod';
import { eq } from 'drizzle-orm'; import { eq, sql } from 'drizzle-orm';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { responses, mastery, journeyInstances } from '@/lib/db/schema'; import { responses, mastery, journeyInstances } from '@/lib/db/schema';
import { getOptionalSession } from '@/lib/auth-helpers'; import { getOptionalSession } from '@/lib/auth-helpers';
@@ -29,6 +29,7 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
} }
const { anonUserId } = parsed.data; const { anonUserId } = parsed.data;
// authUserId always comes from the server session — never trust the client-supplied value
const authUserId = session.user.id; const authUserId = session.user.id;
if (anonUserId === authUserId) { if (anonUserId === authUserId) {
@@ -36,19 +37,44 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
} }
await db.transaction(async (tx) => { 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(responses).set({ userId: authUserId }).where(eq(responses.userId, anonUserId));
await tx
.update(mastery) // Mastery: on conflict keep higher score, more-recent lastSeen, earlier nextReview.
.set({ userId: authUserId }) // Cannot use Drizzle's onConflictDoUpdate on an UPDATE, so use raw INSERT+SELECT.
.where(eq(mastery.userId, anonUserId)) await tx.execute(sql`
.catch(() => { INSERT INTO mastery (user_id, concept_id, score, last_seen, next_review)
// Conflict if auth user already has mastery for same concept — skip SELECT ${authUserId}, concept_id, score, last_seen, next_review
}); FROM mastery
await tx WHERE user_id = ${anonUserId}
.update(journeyInstances) ON CONFLICT (user_id, concept_id) DO UPDATE
.set({ userId: authUserId }) SET score = GREATEST(EXCLUDED.score, mastery.score),
.where(eq(journeyInstances.userId, anonUserId)) last_seen = GREATEST(EXCLUDED.last_seen, mastery.last_seen),
.catch(() => {}); 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 // Rename Redis budget key
@@ -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<string, string> = {}) {
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();
});
});
+94
View File
@@ -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<NextResponse> {
// 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 <CRON_SECRET>
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 });
}
@@ -19,6 +19,7 @@ const VALID_BODY = {
const MOCK_GRADE_RESULT = { const MOCK_GRADE_RESULT = {
responseId: 'resp-1', responseId: 'resp-1',
gradeId: 'grade-1', gradeId: 'grade-1',
referenceAnswer: 'A closure retains access to variables from its enclosing scope.',
grade: { grade: {
verdict: 'mastered' as const, verdict: 'mastered' as const,
rubric_hits: ['r1'], rubric_hits: ['r1'],
+2
View File
@@ -43,6 +43,8 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
responseId: result.responseId, responseId: result.responseId,
gradeId: result.gradeId, gradeId: result.gradeId,
grade: result.grade, grade: result.grade,
newMasteryScore: result.newMasteryScore,
referenceAnswer: result.referenceAnswer,
}); });
} catch (err) { } catch (err) {
if (err instanceof BudgetExceededError) { if (err instanceof BudgetExceededError) {
@@ -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([]);
});
});
+50
View File
@@ -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<NextResponse<HomeSummary>> {
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,
});
}
+68
View File
@@ -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<string, WikiPage>;
};
}
export async function GET(req: NextRequest): Promise<NextResponse> {
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 });
}
}
+2 -1
View File
@@ -50,12 +50,13 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
} }
const { input, userId } = parsed.data; const { input, userId } = parsed.data;
const locale = req.cookies.get('NEXT_LOCALE')?.value ?? 'en';
const { intentKey, blueprintId: existingBlueprintId, isNew } = await normalizeIntent(input); const { intentKey, blueprintId: existingBlueprintId, isNew } = await normalizeIntent(input);
let blueprintId = existingBlueprintId; let blueprintId = existingBlueprintId;
if (isNew) { if (isNew) {
blueprintId = await generateBlueprint({ intentText: input, intentKey }); blueprintId = await generateBlueprint({ intentText: input, intentKey, locale });
} }
let personalizationHint: string | undefined; let personalizationHint: string | undefined;
+4 -1
View File
@@ -67,6 +67,8 @@ const READY_RESPONSE = {
}, },
], ],
}, },
position: 0,
totalLessons: 1,
}; };
const OUTLINE_RESPONSE = { const OUTLINE_RESPONSE = {
@@ -77,6 +79,7 @@ const OUTLINE_RESPONSE = {
concepts: [{ name: 'What is a closure?', ord: 0 }], concepts: [{ name: 'What is a closure?', ord: 0 }],
estimatedMinutes: 10, estimatedMinutes: 10,
}, },
position: 0,
}; };
function setupDbForOutline(existingJob = false) { function setupDbForOutline(existingJob = false) {
@@ -134,7 +137,7 @@ describe('GET /api/lessons', () => {
const body = await res.json(); const body = await res.json();
expect(body.state).toBe('ready'); expect(body.state).toBe('ready');
expect(body.lesson.segments).toHaveLength(1); 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 () => { it('returns 200 outline and enqueues job on cold start', async () => {
+53
View File
@@ -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<NextResponse> {
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',
},
});
}
+45 -10
View File
@@ -1,11 +1,19 @@
import { NextRequest, NextResponse } from 'next/server'; 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 { getCachedLesson, setCachedLesson } from '@/lib/cache/lesson';
import { normalizeDepth } from '@/lib/generation/depth';
import { enqueueGenerateLesson } from '@/lib/jobs/queue'; import { enqueueGenerateLesson } from '@/lib/jobs/queue';
import { getColdStartRatelimit } from '@/lib/ratelimit'; import { getColdStartRatelimit } from '@/lib/ratelimit';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { jobs } from '@/lib/db/schema'; 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=<intentKey> * GET /api/lessons?key=<intentKey>
@@ -20,23 +28,46 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: 'Missing ?key parameter' }, { status: 400 }); 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 // 1. Redis cache
const cached = await getCachedLesson(key); const cached = await getCachedLesson(key, locale, depth, ageGroup);
if (cached?.state === 'ready') { if (cached?.state === 'ready') {
return NextResponse.json(cached); return NextResponse.json(cached);
} }
// 2. DB — pass uid for difficulty variant selection (§13) // 2. DB
const uid = req.nextUrl.searchParams.get('uid') ?? undefined; const result = await getLessonResponse(key, uid, locale, depth, ageGroup);
const result = await getLessonResponse(key, uid);
if (!result) { if (!result) {
return NextResponse.json({ error: 'Blueprint not found' }, { status: 404 }); 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') { if (result.state === 'ready') {
setCachedLesson(key, result).catch(() => {}); setCachedLesson(key, locale, result, depth, ageGroup, result.position).catch(() => {});
return NextResponse.json(result); 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) // Check if job already exists (pending or running)
const [existingJob] = await db const [existingJob] = await db
@@ -67,15 +98,19 @@ export async function GET(req: NextRequest) {
type: 'generate-lesson', type: 'generate-lesson',
idempotencyKey, idempotencyKey,
status: 'pending', status: 'pending',
payloadJson: { blueprintId: result.blueprintId, intentKey: key }, payloadJson: { blueprintId: result.blueprintId, intentKey: key, locale, depth, ageGroup, difficultyLevel },
}) })
.returning({ id: jobs.id }); .returning({ id: jobs.id });
// Enqueue BullMQ job (uses jobId = idempotencyKey to dedup) // Enqueue BullMQ job
await enqueueGenerateLesson({ await enqueueGenerateLesson({
intentKey: key, intentKey: key,
blueprintId: result.blueprintId, blueprintId: result.blueprintId,
idempotencyKey: jobRow.id, idempotencyKey: jobRow.id,
locale,
depth,
ageGroup,
difficultyLevel,
}); });
} }
@@ -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<string, string> = {}) {
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');
});
});
+80
View File
@@ -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<void>((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<Response> {
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',
},
});
}
@@ -17,7 +17,9 @@ const ENTRIES = [
{ {
conceptId: 'c1', conceptId: 'c1',
conceptName: 'Closures', conceptName: 'Closures',
blueprintId: 'bp1',
blueprintTitle: 'JavaScript', blueprintTitle: 'JavaScript',
blueprintIntentKey: 'javascript',
score: 0.8, score: 0.8,
lastSeen: NOW, lastSeen: NOW,
nextReview: LATER, nextReview: LATER,
@@ -25,7 +27,9 @@ const ENTRIES = [
{ {
conceptId: 'c2', conceptId: 'c2',
conceptName: 'Promises', conceptName: 'Promises',
blueprintId: 'bp1',
blueprintTitle: 'JavaScript', blueprintTitle: 'JavaScript',
blueprintIntentKey: 'javascript',
score: 0.45, score: 0.45,
lastSeen: NOW, lastSeen: NOW,
nextReview: NOW, nextReview: NOW,
+40 -5
View File
@@ -1,4 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; 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 { getMasteryMap } from '@/lib/db/queries';
import { getOptionalSession } from '@/lib/auth-helpers'; import { getOptionalSession } from '@/lib/auth-helpers';
@@ -16,14 +19,46 @@ export async function GET(req: NextRequest): Promise<NextResponse> {
const entries = await getMasteryMap(userId); const entries = await getMasteryMap(userId);
// Group by blueprintTitle for the map view // Group by blueprintIntentKey for the map view
const grouped: Record<string, { blueprintTitle: string; concepts: typeof entries }> = {}; const grouped: Record<string, { blueprintId: string; blueprintTitle: string; blueprintIntentKey: string; concepts: typeof entries }> = {};
for (const entry of entries) { for (const entry of entries) {
if (!grouped[entry.blueprintTitle]) { const key = entry.blueprintIntentKey;
grouped[entry.blueprintTitle] = { blueprintTitle: entry.blueprintTitle, concepts: [] }; 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) }); return NextResponse.json({ groups: Object.values(grouped) });
} }
/**
* DELETE /api/mastery?blueprintId=<id>&userId=<id>
* Removes all mastery records and the journey instance for a blueprint.
*/
export async function DELETE(req: NextRequest): Promise<NextResponse> {
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 });
}
+21
View File
@@ -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);
}
@@ -3,6 +3,7 @@ import { NextRequest } from 'next/server';
vi.mock('@/lib/db/queries', () => ({ vi.mock('@/lib/db/queries', () => ({
getReviewCheckpoints: vi.fn(), getReviewCheckpoints: vi.fn(),
getNextReviewAt: vi.fn().mockResolvedValue(null),
})); }));
import { GET } from '../route'; import { GET } from '../route';
+9 -1
View File
@@ -1,11 +1,13 @@
import { NextRequest, NextResponse } from 'next/server'; 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'; import { getOptionalSession } from '@/lib/auth-helpers';
/** /**
* GET /api/review?userId=<userId> * GET /api/review?userId=<userId>
* *
* Returns due checkpoints for spaced-rep review, ordered by overdue-ness. * 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<NextResponse> { export async function GET(req: NextRequest): Promise<NextResponse> {
const session = await getOptionalSession(); const session = await getOptionalSession();
@@ -16,5 +18,11 @@ export async function GET(req: NextRequest): Promise<NextResponse> {
} }
const checkpoints = await getReviewCheckpoints(userId); 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 }); return NextResponse.json({ checkpoints });
} }
+96
View File
@@ -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<string, string> = {
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<NextResponse> {
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<string, unknown>)?.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<string, unknown>)?.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 });
}
}
+15
View File
@@ -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=<id>
* Returns all themes with the user's interacted blueprints per theme.
*/
export async function GET(req: NextRequest): Promise<NextResponse> {
const session = await getOptionalSession();
const userId = session?.user?.id ?? req.nextUrl.searchParams.get('userId') ?? '';
const themes = await getThemeMap(userId);
return NextResponse.json({ themes });
}
+39
View File
@@ -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<Response> {
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(
`<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>Unsubscribed</title>
<style>body{font-family:Georgia,serif;background:#f2f1ee;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
.w{max-width:400px;text-align:center;padding:40px}h1{font-size:1.25rem;color:#1a1a24}p{color:#4a4858}a{color:#2e3a8c}</style>
</head><body><div class="w"><h1>Unsubscribed</h1>
<p>You've been removed from Curio review digests. You can re-enable them in <a href="${BASE_URL}/settings">Settings</a>.</p>
</div></body></html>`,
{ headers: { 'Content-Type': 'text/html; charset=utf-8' } },
);
}
+37
View File
@@ -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<NextResponse> {
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<NextResponse> {
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;
}
+116
View File
@@ -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<ValidState>({ 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<string | null>(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 <main style={s.page}><p style={{ color: 'var(--color-ink-muted)' }}></p></main>;
}
if (valid.status === 'invalid') {
return (
<main style={s.page}>
<p style={{ color: 'var(--color-misconception)' }}>{t('invalidLink')}</p>
<Link href="/auth/login" style={s.link}>{t('goToLogin')}</Link>
</main>
);
}
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 (
<main style={s.page}>
<h1 style={s.heading}>{t('title')}</h1>
<p style={s.subtitle}>{t('subtitle')}</p>
<form onSubmit={handleSubmit} style={s.form} noValidate>
<label style={s.label} htmlFor="email">{t('email')}</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
readOnly={valid.emailLocked}
autoComplete="email"
style={{ ...s.input, ...(valid.emailLocked ? s.inputLocked : null) }}
/>
<label style={s.label} htmlFor="name">{t('name')}</label>
<input id="name" type="text" value={name} onChange={(e) => setName(e.target.value)} required autoComplete="name" style={s.input} />
<label style={s.label} htmlFor="password">{t('password')}</label>
<input id="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required minLength={8} autoComplete="new-password" style={s.input} />
<label style={s.label} htmlFor="confirm">{t('confirmPassword')}</label>
<input id="confirm" type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} required autoComplete="new-password" style={s.input} />
{error && <p role="alert" style={s.error}>{error}</p>}
<button type="submit" disabled={!email || !name || !password || !confirm || loading} style={s.btn}>
{loading ? t('submitting') : t('submit')}
</button>
</form>
</main>
);
}
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;
+10 -19
View File
@@ -1,28 +1,19 @@
'use client'; 'use client';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import Link from 'next/link'; import Link from 'next/link';
const ERROR_MESSAGES: Record<string, string> = { const KNOWN_CODES = ['Configuration', 'AccessDenied', 'Verification'] as const;
Configuration: 'Server configuration error. Contact support.', type KnownCode = (typeof KNOWN_CODES)[number];
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.',
};
export default function AuthErrorPage() { export default function AuthErrorPage() {
const t = useTranslations('auth.error');
const params = useSearchParams(); const params = useSearchParams();
const code = params.get('error') ?? 'Default'; const code = params.get('error') ?? '';
const message = ERROR_MESSAGES[code] ?? ERROR_MESSAGES.Default; const message = KNOWN_CODES.includes(code as KnownCode)
? t(code as KnownCode)
: t('default');
return ( return (
<main <main
@@ -45,7 +36,7 @@ export default function AuthErrorPage() {
color: 'var(--color-ink)', color: 'var(--color-ink)',
}} }}
> >
Sign-in error {t('title')}
</h1> </h1>
<p style={{ color: 'var(--color-misconception)', maxWidth: '30rem' }}>{message}</p> <p style={{ color: 'var(--color-misconception)', maxWidth: '30rem' }}>{message}</p>
<Link <Link
@@ -58,7 +49,7 @@ export default function AuthErrorPage() {
marginTop: 'var(--space-2)', marginTop: 'var(--space-2)',
}} }}
> >
Back to sign in {t('backToSignIn')}
</Link> </Link>
</main> </main>
); );
+11 -11
View File
@@ -2,8 +2,10 @@
import { useState } from 'react'; import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useTranslations } from 'next-intl';
export default function ForgotPasswordPage() { export default function ForgotPasswordPage() {
const t = useTranslations('auth.forgotPassword');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [sent, setSent] = useState(false); const [sent, setSent] = useState(false);
@@ -20,26 +22,24 @@ export default function ForgotPasswordPage() {
}); });
setLoading(false); setLoading(false);
if (res.ok) setSent(true); if (res.ok) setSent(true);
else setError('Something went wrong. Try again.'); else setError(t('error'));
}; };
if (sent) { if (sent) {
return ( return (
<main style={s.page}> <main style={s.page}>
<h1 style={s.heading}>Check your inbox</h1> <h1 style={s.heading}>{t('sentTitle')}</h1>
<p style={s.body}> <p style={s.body}>{t('sentBody')}</p>
If an account exists for that email, we sent a reset link. It expires in 1 hour. <Link href="/auth/login" style={s.link}>{t('backToSignIn')}</Link>
</p>
<Link href="/auth/login" style={s.link}>Back to sign in</Link>
</main> </main>
); );
} }
return ( return (
<main style={s.page}> <main style={s.page}>
<h1 style={s.heading}>Reset password</h1> <h1 style={s.heading}>{t('title')}</h1>
<form onSubmit={handleSubmit} style={s.form} noValidate> <form onSubmit={handleSubmit} style={s.form} noValidate>
<label style={s.label} htmlFor="email">Email</label> <label style={s.label} htmlFor="email">{t('email')}</label>
<input <input
id="email" id="email"
type="email" type="email"
@@ -51,10 +51,10 @@ export default function ForgotPasswordPage() {
/> />
{error && <p role="alert" style={s.error}>{error}</p>} {error && <p role="alert" style={s.error}>{error}</p>}
<button type="submit" disabled={!email || loading} style={s.btn}> <button type="submit" disabled={!email || loading} style={s.btn}>
{loading ? 'Sending' : 'Send reset link'} {loading ? t('submitting') : t('submit')}
</button> </button>
</form> </form>
<Link href="/auth/login" style={s.link}>Back to sign in</Link> <Link href="/auth/login" style={s.link}>{t('backToSignIn')}</Link>
</main> </main>
); );
} }
@@ -67,6 +67,6 @@ const s = {
label: { fontFamily: 'var(--font-display)', fontSize: '0.875rem', fontWeight: 500, color: 'var(--color-ink)', marginTop: 'var(--space-3)' }, 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' }, 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 }, 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' }, link: { color: 'var(--color-accent)', fontFamily: 'var(--font-display)', fontSize: '0.875rem', textDecoration: 'none' },
} as const; } as const;
+29 -16
View File
@@ -4,6 +4,7 @@ import { useState } from 'react';
import { signIn } from 'next-auth/react'; import { signIn } from 'next-auth/react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { useTranslations } from 'next-intl';
interface Props { interface Props {
hasOidc: boolean; hasOidc: boolean;
@@ -13,6 +14,7 @@ interface Props {
} }
export default function LoginClient({ hasOidc, oidcLabel, hasGoogle, hasGitHub }: Props) { export default function LoginClient({ hasOidc, oidcLabel, hasGoogle, hasGitHub }: Props) {
const t = useTranslations('auth.login');
const router = useRouter(); const router = useRouter();
const params = useSearchParams(); const params = useSearchParams();
const callbackUrl = params.get('callbackUrl') ?? '/'; const callbackUrl = params.get('callbackUrl') ?? '/';
@@ -33,7 +35,7 @@ export default function LoginClient({ hasOidc, oidcLabel, hasGoogle, hasGitHub }
}); });
setLoading(false); setLoading(false);
if (res?.error) { if (res?.error) {
setError('Invalid email or password.'); setError(t('error'));
} else { } else {
await migrateThenRedirect(callbackUrl, router); await migrateThenRedirect(callbackUrl, router);
} }
@@ -41,12 +43,12 @@ export default function LoginClient({ hasOidc, oidcLabel, hasGoogle, hasGitHub }
return ( return (
<main style={styles.page}> <main style={styles.page}>
<h1 style={styles.heading}>Sign in</h1> <h1 style={styles.heading}>{t('title')}</h1>
{resetSuccess && ( {resetSuccess && (
<p role="status" style={styles.success}>Password updated. Sign in with your new password.</p> <p role="status" style={styles.success}>{t('resetSuccess')}</p>
)} )}
<form onSubmit={handleCredentials} style={styles.form} noValidate> <form onSubmit={handleCredentials} style={styles.form} noValidate>
<label style={styles.label} htmlFor="email">Email</label> <label style={styles.label} htmlFor="email">{t('email')}</label>
<input <input
id="email" id="email"
type="email" type="email"
@@ -56,7 +58,7 @@ export default function LoginClient({ hasOidc, oidcLabel, hasGoogle, hasGitHub }
autoComplete="email" autoComplete="email"
style={styles.input} style={styles.input}
/> />
<label style={styles.label} htmlFor="password">Password</label> <label style={styles.label} htmlFor="password">{t('password')}</label>
<input <input
id="password" id="password"
type="password" type="password"
@@ -68,34 +70,34 @@ export default function LoginClient({ hasOidc, oidcLabel, hasGoogle, hasGitHub }
/> />
{error && <p role="alert" style={styles.error}>{error}</p>} {error && <p role="alert" style={styles.error}>{error}</p>}
<button type="submit" disabled={loading} style={styles.btn}> <button type="submit" disabled={loading} style={styles.btn}>
{loading ? 'Signing in…' : 'Sign in'} {loading ? t('submitting') : t('submit')}
</button> </button>
<Link href="/auth/forgot-password" style={styles.forgotLink}>Forgot password?</Link> <Link href="/auth/forgot-password" style={styles.forgotLink}>{t('forgotPassword')}</Link>
</form> </form>
{(hasGoogle || hasGitHub || hasOidc) && <div style={styles.divider}><span>or</span></div>} {(hasGoogle || hasGitHub || hasOidc) && <div style={styles.divider}><span>{t('or')}</span></div>}
<div style={styles.oauthGroup}> <div style={styles.oauthGroup}>
{hasGoogle && ( {hasGoogle && (
<button type="button" onClick={() => signIn('google', { callbackUrl })} style={styles.oauthBtn}> <button type="button" onClick={() => signIn('google', { callbackUrl })} style={styles.oauthBtn}>
Continue with Google {t('continueWithGoogle')}
</button> </button>
)} )}
{hasGitHub && ( {hasGitHub && (
<button type="button" onClick={() => signIn('github', { callbackUrl })} style={styles.oauthBtn}> <button type="button" onClick={() => signIn('github', { callbackUrl })} style={styles.oauthBtn}>
Continue with GitHub {t('continueWithGitHub')}
</button> </button>
)} )}
{hasOidc && ( {hasOidc && (
<button type="button" onClick={() => signIn('authentik', { callbackUrl })} style={styles.oauthBtn}> <button type="button" onClick={() => signIn('authentik', { callbackUrl })} style={styles.oauthBtn}>
Continue with {oidcLabel} {t('continueWith', { provider: oidcLabel })}
</button> </button>
)} )}
</div> </div>
<p style={styles.footer}> <p style={styles.footer}>
No account?{' '} {t('noAccount')}{' '}
<Link href="/auth/register" style={styles.link}>Register</Link> <Link href="/auth/register" style={styles.link}>{t('register')}</Link>
</p> </p>
</main> </main>
); );
@@ -115,6 +117,17 @@ async function migrateThenRedirect(callbackUrl: string, router: ReturnType<typeo
// non-blocking // non-blocking
} }
} }
// Sync saved locale preference to cookie so UI reflects user's language
try {
const localeRes = await fetch('/api/user/locale');
if (localeRes.ok) {
const { locale } = (await localeRes.json()) as { locale: string };
document.cookie = `NEXT_LOCALE=${locale};path=/;max-age=${60 * 60 * 24 * 365};samesite=lax`;
}
} catch {
// non-blocking
}
router.refresh();
router.push(callbackUrl); router.push(callbackUrl);
} }
@@ -169,7 +182,7 @@ const styles = {
marginTop: 'var(--space-4)', marginTop: 'var(--space-4)',
padding: 'var(--space-3) var(--space-6)', padding: 'var(--space-3) var(--space-6)',
background: 'var(--color-accent)', background: 'var(--color-accent)',
color: '#fff', color: 'var(--color-on-accent)',
border: 'none', border: 'none',
borderRadius: '4px', borderRadius: '4px',
fontFamily: 'var(--font-display)', fontFamily: 'var(--font-display)',
@@ -225,8 +238,8 @@ const styles = {
width: '100%', width: '100%',
maxWidth: '26rem', maxWidth: '26rem',
padding: 'var(--space-3) var(--space-4)', padding: 'var(--space-3) var(--space-4)',
background: 'var(--color-mastered-bg, #eaf5ea)', background: 'var(--color-mastered-subtle)',
color: 'var(--color-mastered, #1a6e2e)', color: 'var(--color-mastered)',
borderRadius: '4px', borderRadius: '4px',
fontSize: '0.875rem', fontSize: '0.875rem',
margin: 0, margin: 0,
+15 -12
View File
@@ -4,8 +4,10 @@ import { useState } from 'react';
import { signIn } from 'next-auth/react'; import { signIn } from 'next-auth/react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { useTranslations } from 'next-intl';
export default function RegisterPage() { export default function RegisterPage() {
const t = useTranslations('auth.register');
const router = useRouter(); const router = useRouter();
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [name, setName] = useState(''); const [name, setName] = useState('');
@@ -17,7 +19,7 @@ export default function RegisterPage() {
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (password !== confirm) { if (password !== confirm) {
setError('Passwords do not match.'); setError(t('passwordMismatch'));
return; return;
} }
setLoading(true); setLoading(true);
@@ -38,7 +40,7 @@ export default function RegisterPage() {
const signInRes = await signIn('credentials', { email, password, redirect: false }); const signInRes = await signIn('credentials', { email, password, redirect: false });
if (signInRes?.error) { if (signInRes?.error) {
setError('Account created but sign-in failed. Try signing in manually.'); setError(t('signInFailed'));
setLoading(false); setLoading(false);
return; return;
} }
@@ -57,14 +59,15 @@ export default function RegisterPage() {
} }
} }
router.push('/'); router.refresh();
router.push('/onboarding');
}; };
return ( return (
<main style={styles.page}> <main style={styles.page}>
<h1 style={styles.heading}>Create account</h1> <h1 style={styles.heading}>{t('title')}</h1>
<form onSubmit={handleSubmit} style={styles.form} noValidate> <form onSubmit={handleSubmit} style={styles.form} noValidate>
<label style={styles.label} htmlFor="name">Name</label> <label style={styles.label} htmlFor="name">{t('name')}</label>
<input <input
id="name" id="name"
type="text" type="text"
@@ -74,7 +77,7 @@ export default function RegisterPage() {
autoComplete="name" autoComplete="name"
style={styles.input} style={styles.input}
/> />
<label style={styles.label} htmlFor="email">Email</label> <label style={styles.label} htmlFor="email">{t('email')}</label>
<input <input
id="email" id="email"
type="email" type="email"
@@ -84,7 +87,7 @@ export default function RegisterPage() {
autoComplete="email" autoComplete="email"
style={styles.input} style={styles.input}
/> />
<label style={styles.label} htmlFor="password">Password</label> <label style={styles.label} htmlFor="password">{t('password')}</label>
<input <input
id="password" id="password"
type="password" type="password"
@@ -95,7 +98,7 @@ export default function RegisterPage() {
autoComplete="new-password" autoComplete="new-password"
style={styles.input} style={styles.input}
/> />
<label style={styles.label} htmlFor="confirm">Confirm password</label> <label style={styles.label} htmlFor="confirm">{t('confirmPassword')}</label>
<input <input
id="confirm" id="confirm"
type="password" type="password"
@@ -107,12 +110,12 @@ export default function RegisterPage() {
/> />
{error && <p role="alert" style={styles.error}>{error}</p>} {error && <p role="alert" style={styles.error}>{error}</p>}
<button type="submit" disabled={loading} style={styles.btn}> <button type="submit" disabled={loading} style={styles.btn}>
{loading ? 'Creating account…' : 'Create account'} {loading ? t('submitting') : t('submit')}
</button> </button>
</form> </form>
<p style={styles.footer}> <p style={styles.footer}>
Already have an account?{' '} {t('hasAccount')}{' '}
<Link href="/auth/login" style={styles.link}>Sign in</Link> <Link href="/auth/login" style={styles.link}>{t('signIn')}</Link>
</p> </p>
</main> </main>
); );
@@ -169,7 +172,7 @@ const styles = {
marginTop: 'var(--space-4)', marginTop: 'var(--space-4)',
padding: 'var(--space-3) var(--space-6)', padding: 'var(--space-3) var(--space-6)',
background: 'var(--color-accent)', background: 'var(--color-accent)',
color: '#fff', color: 'var(--color-on-accent)',
border: 'none', border: 'none',
borderRadius: '4px', borderRadius: '4px',
fontFamily: 'var(--font-display)', fontFamily: 'var(--font-display)',
+11 -9
View File
@@ -2,9 +2,11 @@
import { useState } from 'react'; import { useState } from 'react';
import { useSearchParams, useRouter } from 'next/navigation'; import { useSearchParams, useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import Link from 'next/link'; import Link from 'next/link';
export default function ResetPasswordPage() { export default function ResetPasswordPage() {
const t = useTranslations('auth.resetPassword');
const params = useSearchParams(); const params = useSearchParams();
const token = params.get('token') ?? ''; const token = params.get('token') ?? '';
const router = useRouter(); const router = useRouter();
@@ -16,15 +18,15 @@ export default function ResetPasswordPage() {
if (!token) { if (!token) {
return ( return (
<main style={s.page}> <main style={s.page}>
<p style={{ color: 'var(--color-misconception)' }}>Invalid reset link.</p> <p style={{ color: 'var(--color-misconception)' }}>{t('invalidLink')}</p>
<Link href="/auth/forgot-password" style={s.link}>Request a new one</Link> <Link href="/auth/forgot-password" style={s.link}>{t('requestNew')}</Link>
</main> </main>
); );
} }
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (password !== confirm) { setError('Passwords do not match.'); return; } if (password !== confirm) { setError(t('passwordMismatch')); return; }
setLoading(true); setLoading(true);
setError(null); setError(null);
const res = await fetch('/api/auth/reset-password', { const res = await fetch('/api/auth/reset-password', {
@@ -37,21 +39,21 @@ export default function ResetPasswordPage() {
router.push('/auth/login?reset=1'); router.push('/auth/login?reset=1');
} else { } else {
const d = (await res.json()) as { error?: string }; const d = (await res.json()) as { error?: string };
setError(d.error ?? 'Failed to reset password.'); setError(d.error ?? t('error'));
} }
}; };
return ( return (
<main style={s.page}> <main style={s.page}>
<h1 style={s.heading}>Set new password</h1> <h1 style={s.heading}>{t('title')}</h1>
<form onSubmit={handleSubmit} style={s.form} noValidate> <form onSubmit={handleSubmit} style={s.form} noValidate>
<label style={s.label} htmlFor="password">New password</label> <label style={s.label} htmlFor="password">{t('newPassword')}</label>
<input id="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required minLength={8} autoComplete="new-password" style={s.input} /> <input id="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required minLength={8} autoComplete="new-password" style={s.input} />
<label style={s.label} htmlFor="confirm">Confirm password</label> <label style={s.label} htmlFor="confirm">{t('confirmPassword')}</label>
<input id="confirm" type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} required autoComplete="new-password" style={s.input} /> <input id="confirm" type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} required autoComplete="new-password" style={s.input} />
{error && <p role="alert" style={s.error}>{error}</p>} {error && <p role="alert" style={s.error}>{error}</p>}
<button type="submit" disabled={!password || !confirm || loading} style={s.btn}> <button type="submit" disabled={!password || !confirm || loading} style={s.btn}>
{loading ? 'Saving' : 'Set password'} {loading ? t('submitting') : t('submit')}
</button> </button>
</form> </form>
</main> </main>
@@ -65,6 +67,6 @@ const s = {
label: { fontFamily: 'var(--font-display)', fontSize: '0.875rem', fontWeight: 500, color: 'var(--color-ink)', marginTop: 'var(--space-3)' }, 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' }, 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 }, 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' }, link: { color: 'var(--color-accent)', fontFamily: 'var(--font-display)', fontSize: '0.875rem', textDecoration: 'none' },
} as const; } as const;
+8 -4
View File
@@ -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 ( return (
<main <main
style={{ style={{
@@ -20,13 +24,13 @@ export default function VerifyEmailPage() {
color: 'var(--color-ink)', color: 'var(--color-ink)',
}} }}
> >
Check your inbox {t('title')}
</h1> </h1>
<p style={{ color: 'var(--color-ink-muted)', maxWidth: '30rem', lineHeight: 'var(--leading-body)' }}> <p style={{ color: 'var(--color-ink-muted)', maxWidth: '30rem', lineHeight: 'var(--leading-body)' }}>
We sent a verification link to your email. Click it to confirm your address and activate your account. {t('body')}
</p> </p>
<p style={{ color: 'var(--color-ink-faint)', fontSize: '0.875rem' }}> <p style={{ color: 'var(--color-ink-faint)', fontSize: '0.875rem' }}>
Didn&apos;t get it? Check spam, or try registering again. {t('subtext')}
</p> </p>
</main> </main>
); );
+2
View File
@@ -2,6 +2,7 @@
@import "../styles/tokens.css"; @import "../styles/tokens.css";
@import "../styles/reading.css"; @import "../styles/reading.css";
@import "../styles/app.css"; @import "../styles/app.css";
@import "../styles/settings.css";
@layer base { @layer base {
*, *,
@@ -62,3 +63,4 @@
} }
} }
} }
+20 -8
View File
@@ -1,21 +1,26 @@
import type { Metadata } from 'next'; 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 { SessionProvider } from 'next-auth/react';
import { NextIntlClientProvider } from 'next-intl';
import { getLocale, getMessages } from 'next-intl/server';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { headers } from 'next/headers'; import { headers } from 'next/headers';
import { auth } from '@/auth'; import { auth } from '@/auth';
import { isAuthRequired } from '@/lib/site-config'; import { isAuthRequired } from '@/lib/site-config';
import { SiteHeader } from '@/components/site-header'; import { SiteHeader } from '@/components/site-header';
import { MigrateOnAuth } from '@/components/migrate-on-auth';
import './globals.css'; import './globals.css';
// Set the persisted theme before first paint to avoid a flash. Inlined; no deps. // 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 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'], subsets: ['latin'],
variable: '--font-body', variable: '--font-body',
style: ['normal', 'italic'], style: ['normal'],
weight: ['300', '400', '500', '600'], weight: ['400'],
display: 'swap', display: 'swap',
}); });
@@ -31,27 +36,34 @@ export const metadata: Metadata = {
}; };
export default async function RootLayout({ children }: { children: React.ReactNode }) { 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) { if (authRequired && !session?.user) {
const hdrs = await headers(); const hdrs = await headers();
const pathname = hdrs.get('x-pathname') ?? '/'; const pathname = hdrs.get('x-pathname') ?? '/';
// Skip redirect loop for auth pages
if (!pathname.startsWith('/auth/')) { if (!pathname.startsWith('/auth/')) {
redirect('/auth/login'); redirect('/auth/login');
} }
} }
return ( return (
<html lang="en" className={`${newsreader.variable} ${inter.variable}`} suppressHydrationWarning> <html lang={locale} className={`${libreCaslon.variable} ${inter.variable}`} suppressHydrationWarning>
<head> <head>
<script dangerouslySetInnerHTML={{ __html: THEME_BOOTSTRAP }} /> <script dangerouslySetInnerHTML={{ __html: THEME_BOOTSTRAP }} />
</head> </head>
<body> <body>
<SessionProvider> <NextIntlClientProvider locale={locale} messages={messages}>
<SessionProvider session={session}>
<MigrateOnAuth />
<SiteHeader /> <SiteHeader />
{children} {children}
</SessionProvider> </SessionProvider>
</NextIntlClientProvider>
</body> </body>
</html> </html>
); );
+37 -10
View File
@@ -1,38 +1,65 @@
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { after } from 'next/server';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { getCachedLesson } from '@/lib/cache/lesson'; import { getLocale } from 'next-intl/server';
import { getCachedLesson, setCachedLesson } from '@/lib/cache/lesson';
import { getLessonResponse } from '@/lib/db/queries'; import { getLessonResponse } from '@/lib/db/queries';
import { ensureLessonGenerated } from '@/lib/generation/ensure-lesson';
import { normalizeDepth } from '@/lib/generation/depth';
import { checkAndEnqueueIfStale } from '@/lib/generation/staleness';
import { LessonReader } from '@/components/lesson-reader'; import { LessonReader } from '@/components/lesson-reader';
interface PageProps { interface PageProps {
params: Promise<{ key: string }>; params: Promise<{ key: string }>;
searchParams: Promise<{ uid?: string }>; searchParams: Promise<{ uid?: string; depth?: string }>;
} }
export async function generateMetadata({ params }: PageProps): Promise<Metadata> { export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { key } = await params; const { key } = await params;
return { title: `${key} — Curio` }; const data = await getLessonResponse(key);
const title = data?.blueprintTitle ?? key.replace(/-/g, ' ');
return { title: `${title} — Curio` };
} }
/** /**
* Learn page Server Component. * Learn page Server Component.
* *
* Reads from DB directly (no fetch()). No generation happens here. * Reads from DB (cache DB). Cold-start gate: if no lesson exists yet,
* Generation runs in background jobs (P3); this page serves what exists. * fires generation after the response via `after()` (Next.js 15). The outline
* page renders immediately; LessonReader polls until the lesson is ready.
* *
* When ?uid= is present, variant difficulty is selected based on the learner's mastery (§13). * When ?uid= is present, variant difficulty is selected based on the learner's mastery (§13).
* Cached lessons always serve at the level they were cached; bust the cache to re-select.
*/ */
export default async function LearnPage({ params, searchParams }: PageProps) { export default async function LearnPage({ params, searchParams }: PageProps) {
const { key } = await params; const { key } = await params;
const { uid } = await searchParams; const { uid, depth: depthParam } = await searchParams;
const locale = await getLocale();
const depth = normalizeDepth(depthParam);
// Cached lesson doesn't carry userId-specific difficulty — serve it directly if present // For anon users (no uid), position is always 0 — safe to use shared cache.
const cached = await getCachedLesson(key); // For auth users, getLessonResponse reads position from journeyInstances; cache includes position in key.
if (!uid) {
const cached = await getCachedLesson(key, locale, depth);
if (cached) return <LessonReader data={cached} />; if (cached) return <LessonReader data={cached} />;
}
const data = await getLessonResponse(key, uid); const data = await getLessonResponse(key, uid, locale, depth);
if (!data) notFound(); if (!data) notFound();
if (data.state === 'outline') {
// Fire generation after response — polling in LessonReader handles transition.
after(() => ensureLessonGenerated(data!.blueprintId, data!.blueprintTitle, key, locale, depth));
} else {
// Warm per-position cache for future requests.
const pos = data.position;
after(() => getCachedLesson(key, locale, depth, 'adult', pos).then((hit) => {
if (!hit) return setCachedLesson(key, locale, data!, depth, 'adult', pos);
}).catch(() => {}));
// Stale check: if model or prompt version changed, enqueue lazy regeneration.
after(() => checkAndEnqueueIfStale(data!.blueprintId, key).catch((err) =>
console.warn('[staleness] enqueue failed (non-fatal):', err),
));
}
return <LessonReader data={data} />; return <LessonReader data={data} />;
} }
+347
View File
@@ -0,0 +1,347 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import { useLocale } from 'next-intl';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { MASTERY_THRESHOLD } from '@/lib/memory/bkt';
// ─── Types ─────────────────────────────────────────────────────────────────
interface ThemeBlueprint {
blueprintId: string;
title: string;
intentKey: string;
masteryScore: number | null;
}
interface Theme {
themeId: string;
slug: string;
labelEn: string;
labelFr: string;
emoji: string;
blueprints: ThemeBlueprint[];
}
// ─── Graph geometry ─────────────────────────────────────────────────────────
const THEME_SLUGS_ORDER = [
'sciences-naturelles',
'mathematiques',
'technologie-informatique',
'economie-societe',
'histoire',
'geographie',
'sport-activite',
'sante-medecine',
'philosophie-ethique',
'langues-litterature',
'arts-culture',
'autre',
];
// Edges between adjacent themes (hardcoded adjacency per plan)
const EDGES: [string, string][] = [
['sciences-naturelles', 'mathematiques'],
['sciences-naturelles', 'geographie'],
['sciences-naturelles', 'sante-medecine'],
['sciences-naturelles', 'technologie-informatique'],
['mathematiques', 'technologie-informatique'],
['mathematiques', 'economie-societe'],
['histoire', 'geographie'],
['histoire', 'philosophie-ethique'],
['histoire', 'arts-culture'],
['histoire', 'langues-litterature'],
['geographie', 'economie-societe'],
['geographie', 'sport-activite'],
['langues-litterature', 'arts-culture'],
['langues-litterature', 'philosophie-ethique'],
['arts-culture', 'philosophie-ethique'],
['technologie-informatique', 'economie-societe'],
['philosophie-ethique', 'economie-societe'],
['sante-medecine', 'sport-activite'],
];
function radialPositions(count: number, cx: number, cy: number, r: number) {
return Array.from({ length: count }, (_, i) => {
const angle = (i / count) * 2 * Math.PI - Math.PI / 2;
return { x: cx + r * Math.cos(angle), y: cy + r * Math.sin(angle) };
});
}
function themeColor(bps: ThemeBlueprint[]): 'mastered' | 'learning' | 'untouched' {
if (bps.length === 0) return 'untouched';
const avg = bps.reduce((s, b) => s + (b.masteryScore ?? 0), 0) / bps.length;
if (avg >= MASTERY_THRESHOLD) return 'mastered';
if (avg > 0) return 'learning';
return 'untouched';
}
// ─── Helpers ────────────────────────────────────────────────────────────────
function getSessionUserId(): string {
if (typeof window === 'undefined') return 'anon';
let id = sessionStorage.getItem('curio_uid');
if (!id) { id = crypto.randomUUID(); sessionStorage.setItem('curio_uid', id); }
return id;
}
// ─── Sub-components ─────────────────────────────────────────────────────────
function ThemeCard({
theme,
label,
onExplore,
}: {
theme: Theme;
label: string;
onExplore: (slug: string) => void;
}) {
const color = themeColor(theme.blueprints);
const started = theme.blueprints.length > 0;
return (
<div className={`theme-card theme-card--${color}`}>
<div className="theme-card-top">
<span className="theme-card-emoji" aria-hidden="true">{theme.emoji}</span>
<span className="theme-card-label">{label}</span>
</div>
{started && (
<ul className="theme-card-bps">
{theme.blueprints.slice(0, 3).map((bp) => (
<li key={bp.blueprintId}>
<Link href={`/learn/${bp.intentKey}`} className="theme-card-bp-link">
{bp.title}
</Link>
</li>
))}
{theme.blueprints.length > 3 && (
<li className="theme-card-bp-more">+{theme.blueprints.length - 3}</li>
)}
</ul>
)}
<button
type="button"
className="theme-card-explore"
onClick={() => onExplore(theme.slug)}
>
Explorer
</button>
</div>
);
}
// ─── Graph view ──────────────────────────────────────────────────────────────
function ThemeGraph({
themes,
locale,
onExplore,
}: {
themes: Theme[];
locale: string;
onExplore: (slug: string) => void;
}) {
const [hovered, setHovered] = useState<string | null>(null);
const SIZE = 520;
const CX = SIZE / 2;
const CY = SIZE / 2;
const R = 200;
const NODE_R = 36;
// Sort themes into the canonical order
const ordered = useMemo(() => {
const map = new Map(themes.map((t) => [t.slug, t]));
return THEME_SLUGS_ORDER.map((slug) => map.get(slug)).filter((t): t is Theme => !!t);
}, [themes]);
const positions = radialPositions(ordered.length, CX, CY, R);
const posMap = new Map(ordered.map((t, i) => [t.slug, positions[i]!]));
return (
<div className="theme-graph-wrap">
<svg
viewBox={`0 0 ${SIZE} ${SIZE}`}
className="theme-graph-svg"
aria-label="Theme map graph"
role="img"
>
{/* Edges */}
{EDGES.map(([a, b]) => {
const pa = posMap.get(a);
const pb = posMap.get(b);
if (!pa || !pb) return null;
const isActive = hovered === a || hovered === b;
return (
<line
key={`${a}-${b}`}
x1={pa.x} y1={pa.y}
x2={pb.x} y2={pb.y}
className={`theme-graph-edge${isActive ? ' theme-graph-edge--active' : ''}`}
/>
);
})}
{/* Nodes */}
{ordered.map((theme, i) => {
const pos = positions[i]!;
const label = locale === 'fr' ? theme.labelFr : theme.labelEn;
const color = themeColor(theme.blueprints);
const isHovered = hovered === theme.slug;
return (
<g
key={theme.slug}
className={`theme-graph-node theme-graph-node--${color}${isHovered ? ' theme-graph-node--hovered' : ''}`}
transform={`translate(${pos.x},${pos.y})`}
onClick={() => onExplore(theme.slug)}
onMouseEnter={() => setHovered(theme.slug)}
onMouseLeave={() => setHovered(null)}
role="button"
tabIndex={0}
aria-label={`Explorer ${label}`}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onExplore(theme.slug); }}
style={{ cursor: 'pointer' }}
>
<circle r={NODE_R} className="theme-graph-node-circle" />
<text
textAnchor="middle"
dominantBaseline="middle"
dy="-8"
className="theme-graph-node-emoji"
>
{theme.emoji}
</text>
<foreignObject
x={-NODE_R}
y={4}
width={NODE_R * 2}
height={26}
overflow="visible"
>
<div className="theme-graph-node-label">
{label}
</div>
</foreignObject>
</g>
);
})}
</svg>
{/* Legend */}
<div className="theme-graph-legend">
<span className="theme-graph-legend-item theme-graph-legend-item--mastered">Maîtrisé</span>
<span className="theme-graph-legend-item theme-graph-legend-item--learning">En cours</span>
<span className="theme-graph-legend-item theme-graph-legend-item--untouched">Non exploré</span>
</div>
</div>
);
}
// ─── Main component ──────────────────────────────────────────────────────────
export function ThemeMapClient() {
const locale = useLocale();
const router = useRouter();
const [themes, setThemes] = useState<Theme[]>([]);
const [loading, setLoading] = useState(true);
const [view, setView] = useState<'list' | 'graph'>('list');
useEffect(() => {
const userId = getSessionUserId();
fetch(`/api/themes?userId=${encodeURIComponent(userId)}`)
.then((r) => r.json())
.then((data: { themes: Theme[] }) => setThemes(data.themes ?? []))
.catch(() => setThemes([]))
.finally(() => setLoading(false));
}, []);
const handleExplore = (slug: string) => {
router.push(`/?theme=${encodeURIComponent(slug)}`);
};
const getLabel = (theme: Theme) => locale === 'fr' ? theme.labelFr : theme.labelEn;
// Only show themes with blueprints in list view; show all in graph view
const listThemes = themes.filter((t) => t.blueprints.length > 0);
if (loading) {
return (
<main className="theme-map-surface">
<p className="theme-map-loading">Chargement</p>
</main>
);
}
return (
<main className="theme-map-surface">
<header className="theme-map-header">
<div>
<h1 className="theme-map-title">Carte des thèmes</h1>
<p className="theme-map-subtitle">Explorez un domaine pour commencer à apprendre.</p>
</div>
<div className="theme-map-view-toggle" role="group" aria-label="Vue">
<button
type="button"
className={`theme-map-toggle-btn${view === 'list' ? ' theme-map-toggle-btn--active' : ''}`}
onClick={() => setView('list')}
aria-pressed={view === 'list'}
>
Liste
</button>
<button
type="button"
className={`theme-map-toggle-btn${view === 'graph' ? ' theme-map-toggle-btn--active' : ''}`}
onClick={() => setView('graph')}
aria-pressed={view === 'graph'}
>
Graphe
</button>
</div>
</header>
{view === 'graph' ? (
<ThemeGraph themes={themes} locale={locale} onExplore={handleExplore} />
) : listThemes.length === 0 ? (
<div className="theme-map-empty">
<p>Aucun thème exploré pour l'instant.</p>
<p>
<a href="/" className="theme-map-empty-link">Commencer à apprendre </a>
</p>
</div>
) : (
<div className="theme-card-grid">
{listThemes.map((theme) => (
<ThemeCard
key={theme.themeId}
theme={theme}
label={getLabel(theme)}
onExplore={handleExplore}
/>
))}
</div>
)}
{/* In list view, show all themes as quick-explore pills below */}
{view === 'list' && (
<section className="theme-explore-all">
<h2 className="theme-explore-all-heading">Explorer un nouveau thème</h2>
<div className="theme-pill-row">
{themes.map((theme) => (
<button
key={theme.themeId}
type="button"
className="theme-pill"
onClick={() => handleExplore(theme.slug)}
>
<span aria-hidden="true">{theme.emoji}</span>
{getLabel(theme)}
</button>
))}
</div>
</section>
)}
</main>
);
}
+10
View File
@@ -0,0 +1,10 @@
import type { Metadata } from 'next';
import { ThemeMapClient } from './map-client';
export const metadata: Metadata = {
title: 'Carte des thèmes — Curio',
};
export default function ThemeMapPage() {
return <ThemeMapClient />;
}
+229
View File
@@ -0,0 +1,229 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { AGE_GROUPS } from '@/schemas/preferences';
export default function OnboardingPage() {
const t = useTranslations('onboarding');
const router = useRouter();
const [ageGroup, setAgeGroup] = useState<'child' | 'teen' | 'adult'>('adult');
const [difficulty, setDifficulty] = useState<1 | 2 | 3>(1);
const [depth, setDepth] = useState<'quick' | 'standard' | 'deep'>('standard');
const [hint, setHint] = useState('');
const [saving, setSaving] = useState(false);
const submit = async (skip = false) => {
if (!skip) {
setSaving(true);
await fetch('/api/preferences', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ageGroup,
defaultDifficulty: difficulty,
defaultDepth: depth,
personalizationHint: hint || undefined,
}),
});
}
router.push('/');
};
return (
<main style={s.page}>
<div style={s.card}>
<h1 style={s.title}>{t('title')}</h1>
<p style={s.subtitle}>{t('subtitle')}</p>
<div style={s.field}>
<label style={s.label}>{t('ageGroup.label')}</label>
<div style={s.radioGroup}>
{AGE_GROUPS.map((ag) => (
<label key={ag} style={s.radioLabel}>
<input
type="radio"
name="ageGroup"
value={ag}
checked={ageGroup === ag}
onChange={() => setAgeGroup(ag)}
style={s.radio}
/>
{t(`ageGroup.${ag}`)}
</label>
))}
</div>
</div>
<div style={s.field}>
<label style={s.label}>{t('difficulty.label')}</label>
<div style={s.radioGroup}>
{([1, 2, 3] as const).map((d) => (
<label key={d} style={s.radioLabel}>
<input
type="radio"
name="difficulty"
value={d}
checked={difficulty === d}
onChange={() => setDifficulty(d)}
style={s.radio}
/>
{t(`difficulty.${d}`)}
</label>
))}
</div>
</div>
<div style={s.field}>
<label style={s.label}>{t('depth.label')}</label>
<div style={s.radioGroup}>
{(['quick', 'standard', 'deep'] as const).map((dp) => (
<label key={dp} style={s.radioLabel}>
<input
type="radio"
name="depth"
value={dp}
checked={depth === dp}
onChange={() => setDepth(dp)}
style={s.radio}
/>
{t(`depth.${dp}`)}
</label>
))}
</div>
</div>
<div style={s.field}>
<label style={s.label} htmlFor="ob-hint">{t('hint.label')}</label>
<p style={s.meta}>{t('hint.description')}</p>
<textarea
id="ob-hint"
value={hint}
onChange={(e) => setHint(e.target.value)}
placeholder={t('hint.placeholder')}
maxLength={300}
rows={3}
style={s.textarea}
/>
</div>
<div style={s.actions}>
<button
type="button"
disabled={saving}
onClick={() => submit(false)}
style={s.submitBtn}
>
{saving ? t('submitting') : t('submit')}
</button>
<button
type="button"
onClick={() => submit(true)}
style={s.skipBtn}
>
{t('skip')}
</button>
</div>
</div>
</main>
);
}
const s = {
page: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100svh',
padding: 'var(--space-8)',
},
card: {
width: '100%',
maxWidth: '32rem',
display: 'flex',
flexDirection: 'column' as const,
gap: 'var(--space-6)',
},
title: {
fontFamily: 'var(--font-display)',
fontSize: '1.75rem',
fontWeight: 500,
color: 'var(--color-ink)',
marginBottom: 0,
},
subtitle: {
fontSize: '1rem',
color: 'var(--color-ink-muted)',
marginTop: 0,
lineHeight: '1.5',
},
field: {
display: 'flex',
flexDirection: 'column' as const,
gap: 'var(--space-2)',
},
label: {
fontFamily: 'var(--font-display)',
fontSize: '0.875rem',
fontWeight: 600,
color: 'var(--color-ink)',
textTransform: 'uppercase' as const,
letterSpacing: '0.05em',
},
radioGroup: {
display: 'flex',
flexDirection: 'column' as const,
gap: 'var(--space-1)',
},
radioLabel: {
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2)',
fontFamily: 'var(--font-body)',
fontSize: '0.9375rem',
color: 'var(--color-ink)',
cursor: 'pointer',
},
radio: { accentColor: 'var(--color-accent)', width: '1rem', height: '1rem', cursor: 'pointer' },
meta: { fontSize: '0.875rem', color: 'var(--color-ink-muted)', margin: 0 },
textarea: {
padding: 'var(--space-3) var(--space-4)',
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',
resize: 'vertical' as const,
lineHeight: '1.5',
},
actions: {
display: 'flex',
alignItems: 'center',
gap: 'var(--space-4)',
paddingTop: 'var(--space-2)',
},
submitBtn: {
padding: 'var(--space-3) var(--space-7)',
background: 'var(--color-accent)',
color: 'var(--color-on-accent)',
border: 'none',
borderRadius: '4px',
fontFamily: 'var(--font-display)',
fontSize: '0.9375rem',
fontWeight: 500,
cursor: 'pointer',
},
skipBtn: {
padding: 'var(--space-3) var(--space-4)',
background: 'transparent',
color: 'var(--color-ink-muted)',
border: 'none',
fontFamily: 'var(--font-body)',
fontSize: '0.9375rem',
cursor: 'pointer',
},
} as const;

Some files were not shown because too many files have changed in this diff Show More