feat: initial commit — Curio mastery tutor

Full Next.js App Router application with:
- AI-graded lesson checkpoints (BKT/Elo mastery tracking)
- Auth.js v5: credentials, Google, GitHub, generic OIDC
- Anonymous-session-first with migrate-on-signin
- Admin panel: users, blueprints, reports, site settings
- Password reset + email verification (nodemailer/SMTP)
- Site config: require_auth + signups_enabled flags
- server-only guards on all DB/generation/verification modules
- PostgreSQL 16 + pgvector, Redis cache, Drizzle ORM
- 271 unit tests (Vitest), golden-eval harness, Playwright e2e stubs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 22:25:43 +02:00
commit 4e38b5a791
194 changed files with 30789 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
DATABASE_URL=postgresql://curio:curio@localhost:5433/curio
# Auth.js v5
AUTH_SECRET= # openssl rand -hex 32
AUTH_URL=http://localhost:3000
AUTH_GOOGLE_ID=
AUTH_GOOGLE_SECRET=
AUTH_GITHUB_ID=
AUTH_GITHUB_SECRET=
# Authentik / generic OIDC (optional — any OIDC-compliant IdP: Keycloak, Dex, etc.)
# Leave AUTH_OIDC_ISSUER blank to disable the SSO button entirely.
AUTH_OIDC_ISSUER= # e.g. https://authentik.example.com/application/o/curio/
AUTH_OIDC_CLIENT_ID=
AUTH_OIDC_CLIENT_SECRET=
AUTH_OIDC_DISPLAY_NAME=SSO # Label shown on the login button
# Email verification (optional — nodemailer)
SMTP_HOST=smtp.eu.mailgun.org
SMTP_PORT=587
SMTP_USER=curio@arnaudne.fr
SMTP_PASS=1e314233c1b2c72b89dee03622bde622-0b3150f2-43f373c4
EMAIL_FROM=curio@arnaudne.fr
# Langfuse — LLM call observability (optional; logs to console when absent)
LANGFUSE_SECRET_KEY=
LANGFUSE_PUBLIC_KEY=
LANGFUSE_HOST=https://cloud.langfuse.com
# Per-session output token cap (~50k ≈ 34 full lessons of grading + probes)
SESSION_TOKEN_BUDGET=50000
REDIS_URL=redis://localhost:6379
# Upstash Redis (REST API) — cold-start rate limiter; optional, degrades gracefully
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
# Generator model — strong, creative (defaults to Anthropic Claude)
LLM_GENERATOR_PROVIDER=anthropic
LLM_GENERATOR_MODEL=claude-sonnet-4-6
ANTHROPIC_API_KEY=
# Grader model — different provider for decorrelation (defaults to OpenAI)
LLM_GRADER_PROVIDER=openai
LLM_GRADER_MODEL=gpt-4o-mini
OPENAI_API_KEY=
# Cheap grader — fast/low-cost model for rubric-matched majority cases (§10.2 cascade)
# Falls back to LLM_GRADER_* when not set (no performance benefit, but safe default)
LLM_CHEAP_GRADER_PROVIDER=openai
LLM_CHEAP_GRADER_MODEL=gpt-4o-mini
# Safety mode: open | controlled (default) | enterprise-safe (§14)
CONTENT_SAFETY_MODE=controlled
# Embedding model — used for RAG retrieval (defaults to OpenAI)
LLM_EMBED_PROVIDER=openai
LLM_EMBED_MODEL=text-embedding-3-small
# ── Google Gemini (provider=google) ──────────────────────────────────────────
# Models: gemini-2.0-flash, gemini-2.5-pro, etc. Supports embeddings.
GOOGLE_GENERATIVE_AI_API_KEY=
# ── Mistral (provider=mistral) ────────────────────────────────────────────────
# Models: mistral-large-latest, mistral-small-latest. Supports embeddings.
MISTRAL_API_KEY=
# ── Cohere (provider=cohere) ──────────────────────────────────────────────────
# Models: command-r-plus, command-r. Supports embeddings (embed-english-v3.0).
COHERE_API_KEY=
# ── Groq (provider=groq) ──────────────────────────────────────────────────────
# Models: llama-3.3-70b-versatile, mixtral-8x7b-32768. No embeddings.
GROQ_API_KEY=
# ── xAI / Grok (provider=xai) ────────────────────────────────────────────────
# Models: grok-3, grok-3-mini. No embeddings.
XAI_API_KEY=
# ── AWS Bedrock (provider=bedrock) ────────────────────────────────────────────
# Models: anthropic.claude-sonnet-4-6-v1, amazon.titan-embed-text-v2:0, etc.
# Uses AWS SDK credential chain (env vars, ~/.aws/credentials, IAM role).
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
# ── Azure OpenAI (provider=azure) ─────────────────────────────────────────────
# model = deployment name (not model name). Supports embeddings.
AZURE_OPENAI_RESOURCE_NAME=
AZURE_API_KEY=
# ── OpenRouter (provider=openrouter) ─────────────────────────────────────────
# Routes to any model. No embeddings — keep LLM_EMBED_PROVIDER=openai.
# Model IDs: "anthropic/claude-sonnet-4-6", "google/gemini-2.0-flash", etc.
OPENROUTER_API_KEY=
# ── Ollama (provider=ollama) ──────────────────────────────────────────────────
# Local. No API key. Supports embeddings (nomic-embed-text).
OLLAMA_BASE_URL=http://localhost:11434/api
# ── Fully local example (no API keys) ────────────────────────────────────────
# LLM_GENERATOR_PROVIDER=ollama
# LLM_GENERATOR_MODEL=llama3.2
# LLM_GRADER_PROVIDER=ollama
# LLM_GRADER_MODEL=llama3.2
# LLM_EMBED_PROVIDER=ollama
# LLM_EMBED_MODEL=nomic-embed-text
+3
View File
@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
}
+19
View File
@@ -0,0 +1,19 @@
# dependencies
node_modules
.pnpm-store
# Next.js
.next
out
# environment
.env.local
.env*.local
# drizzle — migrations and meta are committed (drizzle-kit needs the journal)
# misc
.DS_Store
*.log
npm-debug.log*
pnpm-debug.log*
+102
View File
@@ -0,0 +1,102 @@
# CLAUDE.md — Curio
Repo context for Claude Code. Read this every session. Keep it current: if a convention changes, update this file in the same commit. Full product/architecture/design reference: `docs/curio-specification.md`.
## What Curio is (one paragraph)
Curio is a curiosity-led **mastery tutor**. A learner states intent → gets a Journey of bounded ~10-minute **Lessons** → each Lesson is short readable AI-generated explanation segments punctuated by **production checkpoints** (predict / explain-back / solve) → the AI **diagnoses** the learner's free-text response against pre-verified rubrics and a per-concept misconception library, gives targeted feedback, and gates advancement on demonstrated mastery. The moat is **diagnosis** (inferring *what* a learner misunderstands and *why*), not content generation.
## Decided stack — single language (TypeScript), do not substitute without asking
- **App:** Next.js (App Router) + TypeScript — RSC reading surface + Route Handlers (API) + native streaming/SSE
- **LLM:** Vercel AI SDK (provider-agnostic, streaming, schema-constrained structured output). Generator and grader configured to **different** models/providers.
- **Contracts:** Zod — shared across API I/O, LLM outputs, DB-derived types
- **DB:** PostgreSQL 16 + pgvector (single store, source of truth)
- **ORM/migrations:** Drizzle ORM + drizzle-kit
- **Cache/buffer:** Redis — a cache/buffer in *front* of Postgres, never load-bearing for availability
- **Background jobs:** Route Handlers + the `job` ledger table for the slice; **BullMQ** (Redis) when scale arrives — jobs spend money, so they must be **idempotent** with durable Postgres state
- **Styling/motion:** Tailwind + CSS custom properties (design tokens); motion via CSS transitions + **View Transitions API** (no animation library); honor `prefers-reduced-motion`
- **Observability:** Langfuse (or OpenTelemetry) for LLM call tracing
- **Rate limiting:** edge limiter (e.g. Upstash) on the cold-start generation endpoint
- **Tests:** Vitest (unit, LLM mocked) + golden-eval runner (real-model, CI) + Playwright (e2e)
- **Local infra:** docker-compose (postgres+pgvector, redis)
## Repo structure
```
curio/
CLAUDE.md
docker-compose.yml
docs/curio-specification.md
src/
app/ # Next.js App Router: reading surface + api/ route handlers
components/
lib/
llm/ # provider-agnostic client (Vercel AI SDK) + versioned prompt registry
generation/ # RAG-grounded content generation
verification/ # content cascade (T0T2) + response grading
memory/ # mastery (BKT/Elo) + spaced repetition
intent/ # intent normalization + blueprint lookup
db/ # drizzle schema, queries, migrations
schemas/ # zod contracts (shared)
styles/ # design tokens
jobs/ # BullMQ workers (deferred until P3)
tests/golden/ # frozen eval sets: content verification + response grading
e2e/ # playwright
```
## Architectural invariants — violating these is a bug
1. **All LLM access goes through `src/lib/llm/client.ts`.** Never call a provider/AI-SDK model directly elsewhere. Generator and grader pull their model from config; they must differ.
2. **Prompts live in the versioned registry** (`src/lib/llm/prompts/`), never inline. Each has an id + version.
3. **Contracts are Zod schemas** in `src/schemas/`, shared by the LLM layer, the API, and the UI. LLM structured output is validated against them.
4. **The serve path reads from the Redis buffer; jobs generate.** No synchronous content generation in request handlers except the documented cold-start gate. Postgres is the source of truth — a Redis outage degrades latency, not availability.
5. **Generated content stores provenance** (`source_chunk_ids`) so verification and grading are *grounded* against sources, not free-judged.
6. **Grading is constrained.** The grader evaluates against a pre-verified rubric + reference answer + misconception library. Verdict ∈ {`mastered`,`partial`,`misconception`,`uncertain`}. It **must** support `uncertain` (abstain → reveal). **Bias against false-fail** — telling a correct learner they're wrong is the worst error.
7. **Verification is a cascade.** T0 structural (free) → T1 cheap-model → T2 strong-model (adversarial, at blueprint promotion). Expensive checks amortized once per blueprint, never per serve.
8. **Paid background jobs are idempotent** (idempotency key + durable `job` state). A retry must never regenerate-and-rebill.
9. **Per-session token budget is enforced in code.** The ~10-min Lesson bound is a real cap.
10. **Content is versioned** by `(intent_key, content_version, model_version)`; invalidation is lazy.
## Design invariants (see spec §7)
- **Thesis:** a calm, editorial reading surface that comes alive only when the learner responds. Not gamified, not a dashboard, not a chat-bubble app.
- **Signature:** diagnosis-as-**marginalia** — feedback threaded against the learner's own words, pointing at the `evidence_span` that tripped them. Spend the design's boldness here; keep everything else quiet.
- **Motion** is meaning, not decoration: reserved for the diagnosis moment (a brief "considering" beat → feedback eases in). CSS/View Transitions only.
- **Reading is the product:** humanist reading serif, generous measure (~6268ch) and leading; display face used with restraint; tokens centralized in `src/styles/`.
- **Quality floor (non-negotiable):** responsive to mobile, visible keyboard focus, reduced-motion respected, WCAG-AA contrast (including every diagnosis-state color).
- **Before building any UI, run the frontend-design two-pass process** (plan tokens → critique for genericness → build). Avoid AI-default looks (cream+serif+terracotta, near-black+acid accent, broadsheet hairlines).
- Copy is design material: warm, specific, plain, on the learner's side; never gamified or condescending.
## Commands
```bash
docker compose up -d # postgres+pgvector, redis
pnpm install # or npm
pnpm db:generate && pnpm db:migrate
pnpm dev # Next.js
pnpm test # vitest (unit; LLM mocked)
pnpm test:golden # real-model eval harness (CI-gated)
pnpm test:e2e # playwright
pnpm lint && pnpm format # eslint + prettier
```
## Testing rules
- Write/extend tests **with** each change; run the suite before declaring a task done.
- **Mock the LLM in unit tests** (deterministic fixtures). Real-model calls only in the golden-eval suite.
- Golden evals (`tests/golden/`) are the bar for the moat: response-grading accuracy (track **false-fail rate**) + content-verification accuracy. Don't regress them; add cases on new failure modes.
- Migrations: never edit an applied drizzle migration; add a new one.
## Out of scope — do NOT build unless explicitly asked
Gamification (XP/badges/leaderboards), infinite feed / social, multi-provider fallback orchestration, knowledge graph, voice, PDF ingestion, accounts (MVP is anonymous-session-first). If a task seems to require one of these, stop and ask.
## Working agreement
- **Plan before coding** for anything non-trivial; surface architecture- or design-affecting choices before implementing.
- Small, focused commits per task. One ticket = one logical change.
- Keep Route Handlers thin; logic lives in `src/lib/`.
- Run the frontend-design planning pass before UI tickets.
- If you change a convention here, update this file in the same commit.
- When unsure between two reasonable designs, ask rather than guess — especially on the grading/verification path and the diagnosis UI.
+84
View File
@@ -0,0 +1,84 @@
# Curio — Claude Code Kickoff & Task Plan
Two parts: the **kickoff prompt** to paste into Claude Code first, and the **phased tickets** to work through after. Both assume `docs/curio-specification.md` and `CLAUDE.md` are in the repo.
---
## PART 1 — Kickoff prompt (paste this first)
> You are building **Curio**, an AI mastery tutor (TypeScript, single stack). Read `CLAUDE.md` and `docs/curio-specification.md` in full before doing anything — `CLAUDE.md` holds the decided stack, repo structure, and the architectural + design invariants, all non-negotiable.
>
> **Do not write feature code yet.** First, produce a plan: propose the initial scaffold (matching the structure in `CLAUDE.md`) — Next.js (App Router, TS) + Tailwind, docker-compose for Postgres+pgvector and Redis, Drizzle schema + migration setup, Vitest, and a stubbed `src/lib/llm/client.ts` wrapping the Vercel AI SDK with generator + grader as distinct configured models, mockable in tests. Include a minimal `src/styles/` design-token file derived from the §7 direction. List the files you'll create and the commands that verify the scaffold runs. **Wait for my approval before implementing.**
>
> After I approve the scaffold, we work through the tickets in this plan **one at a time**, in order. For each ticket: plan briefly, implement, write tests, run the suite, then stop and summarize what changed and how to verify it. Do not start the next ticket until I confirm.
>
> For any ticket that builds UI, **first run the frontend-design two-pass process** (plan tokens → critique for genericness against the §7 brief → build); do not reach for AI-default looks. Honor every invariant in `CLAUDE.md` — especially: all LLM calls go through `src/lib/llm/client.ts`; contracts are shared Zod schemas; the grader supports `uncertain`/abstention and is biased against false-fail; content carries provenance; the serve path reads the buffer while jobs generate; motion is CSS/View-Transitions only. If a ticket appears to require anything in the "Out of scope" list, stop and ask.
**Why this shape:** Claude Code performs best with (1) full context up front, (2) a plan-and-confirm gate before large work, (3) bounded tickets it completes and verifies one at a time, and (4) a fixed set of invariants it can check itself against.
---
## PART 2 — Tickets (build in order; each is a vertical or testable slice)
Format per ticket: **Goal · Scope · Acceptance criteria.** "Done" = acceptance criteria met **and** tests pass **and** invariants upheld.
### P0-1 — Scaffold & infra
- **Goal:** Runnable skeleton.
- **Scope:** Repo structure per `CLAUDE.md`; Next.js (App Router, TS) + Tailwind; docker-compose (pg+pgvector, redis); Drizzle init + first migration; `/api/health` route handler; `src/lib/llm/client.ts` (Vercel AI SDK) with a **mock provider**; `src/schemas/` bootstrapped with Zod; `src/styles/` design tokens; Vitest + eslint/prettier configured.
- **Accept:** `docker compose up` + `pnpm dev` start; `/api/health` returns 200; `pnpm test` runs; LLM client is importable and mockable; tokens load in a sample page.
### P1-1 — Content model + seeded corpus + retrieval
- **Goal:** Grounding substrate for one hardcoded topic.
- **Scope:** Drizzle schema + migrations for `source_chunk`, `concept`, `lesson`, `segment`, `checkpoint`, `misconception`, `job` (see spec §17). Seed a small trusted corpus for ONE topic. pgvector retrieval helper in `src/lib/intent`/`generation`.
- **Accept:** Given a concept, retrieval returns relevant chunks; migrations run clean up/down; seed script idempotent.
### P1-2 — Lesson generation (RAG-grounded)
- **Goal:** Generate one Lesson for the seeded topic.
- **Scope:** `src/lib/generation` produces a Lesson = 23 segments, each with explanation + one checkpoint + **reference answer + rubric**, grounded in retrieved chunks, storing `source_chunk_ids`. Schema-constrained (Zod) structured output via the AI SDK. Prompts in the registry.
- **Accept:** Lesson persists with provenance on every segment; rubric + reference answer present per checkpoint; reproducible against a mocked LLM; one real-model smoke test.
### P1-3 — Serve + render the reading surface (design-critical)
- **Goal:** Learner reads a beautiful Lesson and reaches a checkpoint.
- **Scope:** Route handler returns a Lesson (served from buffer/cache, not generated inline); reading surface renders segments as calm editorial prose with checkpoints punctuating (not peppering); free-text input at each checkpoint. **Run the frontend-design pass first**; implement the §7 direction (reading serif, generous measure, restrained chrome, tokens from `src/styles/`). Cold-start outline is a designed state, not a spinner.
- **Accept:** Reads like a short article; meets the quality floor (responsive, keyboard focus, reduced-motion, AA contrast); no synchronous generation in the request path (assert via test/log).
### P1-4 — Response grading + marginalia (the moat MVP)
- **Goal:** AI diagnoses a free-text response and the tutor responds in the margin.
- **Scope:** `src/lib/verification` grader evaluates the response against the **pre-stored rubric + reference answer + misconception library**; returns the §10.3 grader contract (Zod-validated); supports `uncertain` → reveal model answer; biased against false-fail. UI renders **diagnosis-as-marginalia** threaded to the `evidence_span`, with the single View-Transitions motion moment. Persist `response` + `grade`. Wire Langfuse tracing on the call.
- **Accept:** Correct/partial/misconceived/empty responses each map to sensible verdicts on a mocked grader; `uncertain` triggers reveal; feedback points at the learner's own words; every response→grade is logged with cost/latency.
### P1-5 — Golden eval harness (gate the moat)
- **Goal:** Measure grading quality; prevent regressions.
- **Scope:** `tests/golden/` with labeled `response → expected verdict` cases for the seeded topic; a runner reporting accuracy, **false-fail rate**, misconception-hit rate; wired into CI (`pnpm test:golden`).
- **Accept:** Runs against the real grader and prints metrics; CI fails if false-fail rate exceeds the set threshold; adding a case is trivial.
> **P1 milestone = MVP hypothesis test:** one topic, generate → read → produce → diagnose-in-the-margin → reveal, with measured grading quality. Validate diagnosis quality (and that the marginalia experience lands) before expanding.
### P2-1 — Misconception library quality
- **Goal:** Diagnosis depth. Generate distinct, real, diagnosable misconceptions per concept; verify them (T2).
- **Accept:** Golden misconception-hit rate beats a generic-feedback baseline; misconceptions are distinct and source-grounded.
### P2-2 — Content verification cascade
- **Scope:** T0 structural + T1 cheap-model grounding + **blind checkpoint self-solve**; T2 adversarial at promotion. Amortized per blueprint.
- **Accept:** Bad content (unsupported claim, wrong reference answer) is caught; verification runs once per blueprint, not per serve; golden content-verification metrics tracked.
### P3-1 — Mastery + spaced repetition
- **Scope:** `src/lib/memory`: per-concept mastery (BKT or Elo) updated from grades; spaced-rep schedule; mastery gate on advancement.
- **Accept:** Mastery moves correctly on repeated outcomes; due-review query returns the right concepts.
### P3-2 — Blueprint cache + intent normalization + jobs
- **Scope:** Structured + embedding intent keying; blueprint lookup; **BullMQ** workers for `generate_next` / `promote_to_blueprint` with **idempotency keys + durable `job` state**; Redis buffer with backpressure; edge rate limiter on cold-start.
- **Accept:** Equivalent intents collapse to one key; warm serves are cache hits (no generation); cold path promotes into cache async; a retried job never regenerates-and-rebills.
### P4 — Roadmap features (only when asked, one at a time)
Confidence rating before reveal · Socratic follow-up on `partial` · Daily Review · mastery map · source citations · variant-based personalization · accounts. (XP/feed/social/voice/PDF remain out of scope per `CLAUDE.md`.)
---
## Claude Code workflow tips
- Run `/init` once to seed a `CLAUDE.md`, then replace it with the curated one — keeps it accurate to the actual repo.
- Use **plan mode** for P0-1, any ticket on the grading/verification path, and any UI ticket (pair it with the frontend-design pass).
- Keep context lean: point it at specific files per ticket rather than the whole tree.
- After each ticket, run `pnpm test` + `pnpm test:golden` and report metrics before moving on.
- Treat the golden eval thresholds as the definition of "good enough" — they're how Claude Code knows whether its diagnosis work actually works.
+150
View File
@@ -0,0 +1,150 @@
# Curio
A curiosity-led mastery tutor. Learner states intent → gets a bounded ~10-minute Lesson → AI diagnoses free-text responses against pre-verified rubrics and a per-concept misconception library → gates advancement on demonstrated mastery.
The moat is **diagnosis**: inferring *what* a learner misunderstands and *why*, not content generation.
---
## Prerequisites
- Node.js 20+, pnpm 9+
- Docker (for Postgres + Redis)
- API keys: Anthropic (generator) + OpenAI (grader) at minimum
---
## Quick start
```bash
# 1. Clone and install
pnpm install
# 2. Copy env and fill in API keys
cp .env.example .env.local
# Required: ANTHROPIC_API_KEY, OPENAI_API_KEY
# Optional: LANGFUSE_* for tracing, UPSTASH_* for rate limiting
# 3. Start Postgres (port 5433) + Redis (port 6379)
docker compose up -d
# 4. Run migrations
pnpm drizzle-kit generate && pnpm drizzle-kit migrate
# 5. Seed the JS Closures demo topic
pnpm seed
# 6. Start dev server
pnpm dev
# → http://localhost:3000
```
---
## Environment variables
All vars documented in `.env.example`. Key ones:
| Variable | Required | Description |
|---|---|---|
| `DATABASE_URL` | ✓ | `postgresql://curio:curio@localhost:5433/curio` (docker default) |
| `REDIS_URL` | ✓ | `redis://localhost:6379` (docker default) |
| `ANTHROPIC_API_KEY` | ✓ | Generator model (default: `claude-sonnet-4-6`) |
| `OPENAI_API_KEY` | ✓ | Grader + embeddings (default: `gpt-4o-mini`, `text-embedding-3-small`) |
| `LANGFUSE_SECRET_KEY` | — | LLM call tracing; logs to console when absent |
| `SESSION_TOKEN_BUDGET` | — | Per-session output token cap (default: 50000) |
| `CONTENT_SAFETY_MODE` | — | `open` / `controlled` (default) / `enterprise-safe` |
**Provider-agnostic:** generator, grader, and embeddings are each independently configurable. Supported: Anthropic, OpenAI, Google Gemini, Mistral, Cohere, Groq, xAI, AWS Bedrock, Azure OpenAI, OpenRouter, Ollama (fully local, no API keys). See `.env.example` for all options.
---
## Commands
```bash
pnpm dev # Next.js dev server
pnpm build # Production build
pnpm test # Vitest unit tests (LLM mocked — no API keys needed)
pnpm test:watch # Watch mode
pnpm test:e2e # Playwright end-to-end
pnpm test:golden # Real-model grading eval (CI-gated, needs API keys)
pnpm test:golden:misconceptions # Misconception quality eval
pnpm test:golden:content # Content verification eval
pnpm workers # BullMQ background workers (lesson generation, blueprint promotion)
pnpm seed # Seed JS Closures demo corpus (idempotent)
pnpm seed -- --force # Drop and re-seed
pnpm lint # ESLint
pnpm format # Prettier
pnpm drizzle-kit generate # Generate migration from schema changes
pnpm drizzle-kit migrate # Apply pending migrations
pnpm drizzle-kit studio # Drizzle Studio (DB browser)
```
---
## Architecture
```
Request path (fast, no generation):
GET /api/lessons → Redis cache → Postgres → LessonResponse
Generation path (background, async):
POST /api/lessons cold-start → enqueue BullMQ job → generate-lesson-job
Blueprint promotion (T2 verify + variants) → promote-blueprint-job
Grading (per response):
POST /api/grade → cheapGrader (rubric match) → escalate to strong grader if uncertain/novel
→ persist response + grade → update mastery (BKT) → auto-flag collective failures
LLM:
All calls via src/lib/llm/client.ts — never call providers directly
Prompts in src/lib/llm/prompts/ (versioned registry)
Contracts are Zod schemas in src/schemas/ (shared API ↔ LLM ↔ UI)
```
See [`docs/curio-specification.md`](docs/curio-specification.md) for full product + architecture reference.
---
## Repo structure
```
src/
app/ # Next.js App Router: pages + api/ route handlers
components/ # UI components (LessonReader, GradeDisplay, …)
lib/
llm/ # LLM client + versioned prompt registry
generation/ # RAG-grounded content generation
verification/ # Content cascade (T0T2) + response grader
memory/ # Mastery (BKT/Elo) + spaced repetition
intent/ # Intent normalization + blueprint lookup
db/ # Drizzle schema, queries, migrations, seed data
cache/ # Redis lesson buffer
jobs/ # BullMQ queue helpers
observability/ # Langfuse / OpenTelemetry tracing
schemas/ # Zod contracts (shared)
styles/ # Design tokens (CSS custom properties)
jobs/ # BullMQ workers (generation, promotion)
tests/golden/ # Frozen eval sets: grading accuracy + content verification
e2e/ # Playwright
docs/
curio-specification.md
```
---
## Running workers
Background jobs handle lesson generation and blueprint promotion (T2 verification + difficulty variant generation). Run separately from the web server:
```bash
pnpm workers
```
Workers require Postgres, Redis, and API keys. In production, run as a separate process or container.
---
## Tech stack
Next.js 15 (App Router) · TypeScript · Vercel AI SDK · Zod · PostgreSQL 16 + pgvector · Drizzle ORM · Redis · BullMQ · Tailwind · Vitest · Playwright
+33
View File
@@ -0,0 +1,33 @@
services:
postgres:
image: pgvector/pgvector:pg16
environment:
POSTGRES_DB: curio
POSTGRES_USER: curio
POSTGRES_PASSWORD: curio
ports:
- "5433:5432"
volumes:
- pgdata:/var/lib/postgresql/data
- ./docker/init.sql:/docker-entrypoint-initdb.d/01_init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U curio"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redisdata:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
volumes:
pgdata:
redisdata:
+2
View File
@@ -0,0 +1,2 @@
-- Enable pgvector extension (binary is pre-installed in pgvector/pgvector image)
CREATE EXTENSION IF NOT EXISTS vector;
+381
View File
@@ -0,0 +1,381 @@
# CURIO — PRODUCT & BUILD SPECIFICATION
*An AI mastery tutor. Follow your curiosity.*
---
## 1. OVERVIEW
Curio is a curiosity-led learning platform where a learner states what they want to learn and receives a personalized **Journey** of short, bounded **Lessons**. Lessons are AI-generated, AI-verified, and — crucially — **AI-tutored**: the learner does not passively consume content, they *produce* (predict, explain, solve), and the AI diagnoses what they misunderstand and adapts.
The product is built on one idea: **a generative model can do what a course or a feed cannot — respond to the individual learner.** Curio is organized entirely around that capability.
---
## 2. CORE CONCEPT & DIFFERENTIATOR
Curio is not a course catalog and not a content feed. Learning is **generated, not selected**, and **diagnosed, not just delivered**.
The defensible capability — the moat — is **misconception diagnosis**: inferring *what* a learner misunderstands and *why* from what they actually say, and targeting exactly that. Generating explanations is now commodity; diagnosing a specific learner's wrong mental model and correcting it is not. **The product is built around the misconception model, not the content stream.**
---
## 3. THE LEARNING UNIT: THE LESSON
The atomic unit is a **Lesson**: a self-contained microlearning unit, completable in one sitting (~10 minutes), built from 24 **segments**.
```
Lesson (~10 min, bounded, one sitting)
├─ Segment 1: short readable explanation (~300500 words, reads like an article)
│ └─ Checkpoint: learner produces (predict / explain-back / solve)
│ └─ AI diagnosis + targeted feedback (live)
├─ Segment 2: explanation → Checkpoint → diagnosis
└─ Segment 3: explanation → Checkpoint → diagnosis → Mastery gate
```
Design rules:
- **Reads like an article, not a quiz.** Explanation prose is continuous and calm; checkpoints *punctuate* (24 per Lesson) rather than interrupting every sentence.
- **Bounded.** Clear start and end, finishable in ~10 minutes. The session cap bounds learner attention *and* live token cost.
- **Active within the bound.** A passive and an active 10-minute session cost the same minutes; the active one retains far more. Microlearning brevity is preserved; passivity is removed.
- **Open-ended where possible.** Checkpoints favor free-text production over multiple choice — free text is what makes diagnosis possible.
A **Journey** is an ordered sequence of Lessons for a topic, with adaptive branching driven by the memory engine.
---
## 4. THE TUTORING LOOP
Per checkpoint:
1. **Read** — a short AI-generated explanation segment.
2. **Produce** — the learner predicts, explains in their own words, or solves a small problem.
3. **Diagnose** — the AI evaluates the *actual response* against a pre-verified rubric, reference answer, and per-concept misconception library; it names the specific misunderstanding.
4. **Adapt** — targeted feedback; re-explain differently if it didn't land; escalate difficulty if the learner is solid.
5. **Gate** — advancement is earned by demonstrated production, not by clicks. The outcome feeds the memory engine (§11).
---
## 5. CURIOSITY RAILS
The entry point is open: *"What do you want to learn today?"* Learners may pull tangents mid-Lesson ("wait, why does that happen?"). A tangent spawns a short sub-explanation and then **returns to the checkpoint**, so exploration never decays into aimless dabbling. Curiosity is encouraged; it is always brought back onto the attempt→diagnose loop.
---
## 6. LEARNING MODES
- **Journey mode** — structured progression through a topic's Lessons with adaptive branching and mastery tracking.
- **Daily Review** — surfaces concepts due for reinforcement from the spaced-repetition schedule. This is the substantive retention loop and the intended source of habit, in place of dopamine-driven gamification.
---
## 7. DESIGN LANGUAGE & EXPERIENCE
Curio should feel **modern, human, inspired, and beautiful** — not as decoration, but because the product is a reading-and-thinking environment, and the craft of that environment is part of the teaching. The interface should feel like a brilliant, warm tutor who has set your work down in front of them and is reading it carefully.
### 7.1 Thesis
The experience is **a calm reading surface that quietly comes alive when you respond.** Reading is unhurried and editorial; the moment of diagnosis is where the product breathes. Nothing competes with those two states. This is explicitly *not* a gamified, dashboard-dense, or chatbot-bubble app.
### 7.2 Signature: marginalia
The one memorable, bold element is the **diagnosis-as-marginalia** moment: when a learner submits a checkpoint response, the tutor's feedback arrives threaded against *their own words* — the way a great teacher annotates your answer in the margin, pointing at the exact phrase where the misconception lives (the `evidence_span` from §10.3). The feedback references what the learner actually wrote. Spend the design's boldness here; keep everything around it quiet.
### 7.3 Direction (a starting point, not a mandate)
Build by working the two-pass design process (plan a token system → critique it against this brief for genericness → then build). The following is the intended direction; refine it, don't default to it. Deliberately avoid the current AI-design clichés (cream + high-contrast serif + terracotta; near-black + acid accent; broadsheet hairline columns).
- **Palette — "ink & light."** A soft, slightly cool paper (not cream) as the reading ground; an inky near-black for text; a considered deep indigo as the focus/brand accent (depth, study); a warm ochre reserved *only* for the human tutor moments (marginalia, encouragement). Diagnosis states are calm and legible, never an alarm: mastered = a quiet sage/teal, partial = ochre, misconception = a soft clay/rose that says *let's look again*, never buzzer-red. A genuinely comfortable dark mode (warm charcoal, not pure black).
- **Typography — reading is the product.** A humanist, low-contrast **reading serif** for body (e.g. Newsreader / Source Serif / Literata as candidates), set at a generous measure (~6268ch) and line-height (~1.6); a characterful **display face used with restraint** for the prompt and headings; a humanist **mono** for mastery-map data and labels. Make the type a memorable part of the page, not a neutral delivery vehicle.
- **Motion — meaning, not decoration.** No animation library. Use CSS transitions and the **View Transitions API**. Reserve the one orchestrated motion moment for diagnosis: a brief "considering" beat, then feedback easing into the margin — it should feel like a person responding, not a form validating. Everything else is still. `prefers-reduced-motion` is honored everywhere.
- **Layout — one thing at a time.** Single reading column, generous margins, minimal chrome. The checkpoint is inset in the reading flow; feedback appears as marginalia, not a modal. The mastery map is a separate, richer view — it does not crowd the learn surface.
### 7.4 Voice (copy is design material)
Feedback copy *is* the tutor's character. Warm, specific, plain, and on the learner's side — names the exact thing that tripped them, never condescending, never gamified ("Not quite — the bit that slipped is **", never "❌ Wrong! 10 XP"). Active voice; actions say what they do. Empty and error states give direction in the interface's voice, not apologies.
### 7.5 Quality floor (non-negotiable)
Responsive to mobile; visible keyboard focus; reduced motion respected; WCAG-AA contrast on text and on every diagnosis-state color. Loading is part of the experience — the cold-start outline (§8.3) is a designed state, never a spinner.
### 7.6 Anti-patterns
No XP counters, badges, streak confetti, or leaderboards; no dense dashboard on the learn surface; no generic component-library default look; no chat-bubble UI; no autoplay or attention-grabbing motion.
---
## 8. CONTENT ARCHITECTURE
Curio is a **growing library of validated, generated content that gets personalized** — not a pure live-generation engine. The first learner to request a topic pays the generation cost; everyone after reuses validated content. This makes real-time UX, accuracy, and cost simultaneously feasible.
### 8.1 Three tiers
1. **Blueprint** — the canonical, fully verified Journey for a normalized intent: an ordered list of concepts with Lesson templates. Generated and verified **once**, reused by all learners.
2. **Variants** — pre-generated explanation/checkpoint bodies per concept at 23 difficulty levels, generated and verified at blueprint time.
3. **Live** — for long-tail intents with no blueprint hit: generated fresh through a lighter synchronous gate, then queued for full verification and **promoted into the blueprint cache** so the next learner gets a hit.
Two cost classes follow:
| Cached + verified once, reused by everyone | Live, per learner (pay tokens exactly here) |
|---|---|
| explanation segments | evaluation of the learner's free-text response |
| checkpoints + reference answers + rubrics | misconception diagnosis + targeted feedback |
| worked examples | adaptive re-explanation |
| per-concept misconception libraries | tangent handling |
### 8.2 Intent normalization (drives cache hit rate)
"Learn Python", "teach me python basics", and "i wanna learn python" must collapse to one key:
- **Structured key:** one cheap classification call → `{domain, subtopic, goal_type: skill|exam|concept, level_hint}` → canonical string.
- **Embedding fallback:** embed the raw intent; pgvector nearest-neighbor search against existing blueprint embeddings; reuse if cosine > threshold.
- Miss only when both fail. `goal_type` and `level_hint` are **parameters into** a blueprint, not separate blueprints.
### 8.3 Serve flow (skeleton-first + buffer)
Perceived latency is won by streaming *structure* before *content*: the Journey outline appears in under a second; the learner can't move faster than the first segment arrives.
```
on submit(intent):
key, ctx = normalize(intent) # ~100ms
bp = blueprint_cache.lookup(key, ctx) # exact key, then embedding NN
if bp: # WARM
lesson = hydrate(bp, next_concept, user) # variant SELECTION, ~150ms
buffer.push(session, lesson); stream(lesson)
else: # COLD
bp = gen_blueprint(intent, ctx) # structured call → concept list
stream(bp.outline) # outline visible now (designed state)
lesson = gen_lesson(bp, concept[0], rag) # light sync gate before serve
buffer.push(session, lesson); stream(lesson)
enqueue(promote_to_blueprint, bp) # full verification + cache, async
on advance / buffer_low(session):
enqueue(generate_next, session, next_concept) # background job
```
The serve path reads from a Redis buffer fronting Postgres; a background job refills it. Concurrent generations per session are capped for backpressure. Postgres remains the source of truth — a Redis outage degrades latency, not availability.
---
## 9. AI GENERATION PIPELINE
Generation is **RAG-grounded** to constrain hallucination at the source. Per concept the generator produces, all grounded in retrieved trusted sources and stored with provenance (`source_chunk_ids`):
- explanation segments
- checkpoints (prompts) + **reference answers** + **grading rubrics**
- worked examples
- a **misconception library**: likely wrong mental models for the concept, each with a tag and a diagnostic signature
Rubrics and misconception libraries are first-class generated artifacts — they are what make live grading constrained and verifiable. All prompts live in a versioned registry; generation output is **structured (schema-constrained) JSON** via the model's native structured-output mode.
---
## 10. AI VERIFICATION SYSTEM
Curio verifies with AI on two surfaces. Both are made reliable by the same discipline: **ground the check against real artifacts, decorrelate from the generator, structure the output, and allow abstention.**
### 10.1 Content verification — the cascade
A naive "second LLM checks the card" shares the generator's blind spots and rubber-stamps confident-wrong content. Instead:
- **Grounded, not free-judged.** The verifier checks each atomic claim against the retrieved source chunks ("is this supported by these sources?"), not against its own priors.
- **Independent solving.** The verifier never sees the marked answer; it **solves checkpoints/quizzes blind** (n-sample, majority vote) and compares to the reference answer. Mismatch → flag; sample disagreement → uncertainty.
- **Decorrelation.** A different model/provider and an adversarial prompt, so failure modes don't overlap the generator's.
Run as a **cascade**, so expensive scrutiny is amortized:
| Tier | Runs | Cost | Checks |
|---|---|---|---|
| **T0 Structural** | every item, always | ~free | schema valid; exactly one correct option; reference answer references a real option; refs resolve; non-empty; length bounds |
| **T1 Cheap-model** | every item at generation | low | claim grounding; blind solve; obvious safety. Clears the easy majority, else escalate |
| **T2 Adversarial** | blueprint promotion + escalations + low confidence | medium, **amortized once per blueprint** | full rubric, claim-by-claim grounding, n-sample blind solve, pedagogical soundness, cross-item consistency |
| **T3 Human** | top-traffic templates + persistent uncertainty | high, rare | spot review, golden-set labeling |
Because content is cached at the template level, T2 runs **once per blueprint** and is reused by thousands of learners — near-zero per-serve cost.
### 10.2 Response-grading verification
The AI also grades free-text learner responses. Making this trustworthy:
- **Grade against pre-verified artifacts.** At runtime the grader checks the response against the already-verified rubric + reference answer + misconception library — constrained, not freeform.
- **Misconception matching.** The grader maps the response to a known misconception tag or flags `novel` (→ escalate). Closed-set classification beats open-ended critique.
- **Two-sided error awareness.** A false-pass wastes learning; a **false-fail (telling a correct learner they're wrong) corrodes trust and is worse.** The grader is biased toward generous grading and always reveals its reasoning so the learner can contest.
- **Abstention + fallback.** The grader may return `uncertain` → reveal the model answer for self-assessment rather than confidently mis-grading.
- **Cascade.** A cheap model grades the rubric-matched majority; a strong model handles `novel`/`uncertain` only.
### 10.3 Output contracts
Content verifier:
```json
{
"verdict": "pass | fail | uncertain",
"claims": [{"text": "<atomic claim>", "status": "supported|unsupported|contradicted",
"evidence_chunk_ids": ["..."]}],
"solve_check": {"independent_answers": ["B","B","C"], "majority": "B",
"reference": "B", "agreement": 0.67, "status": "match|mismatch|ambiguous"},
"pedagogy": {"teaches_concept": true, "prereqs_respected": true},
"safety": {"in_scope": true, "mode_compliant": true},
"confidence": 0.0
}
```
Response grader:
```json
{
"verdict": "mastered | partial | misconception | uncertain",
"rubric_hits": ["criterion_id"],
"misconception_tag": "tag | novel | none",
"evidence_span": "<quote from learner response>",
"feedback_directive": "advance | re-explain:<angle> | probe:<question>",
"confidence": 0.0
}
```
Contracts are defined as **Zod schemas** shared between the LLM layer, the API, and the frontend.
### 10.4 Keeping verifiers honest
- **Golden sets** of human-labeled cases for both surfaces; measure precision/recall continuously, tracking **false-fail rate** separately for grading. A model upgrade triggers an offline re-sweep of the cached library.
- **User-signal outer loop (free):** collective checkpoint failure (many learners miss the same item → suspect the reference answer), a report button, and dwell-time anomalies auto-trigger re-verification.
- **Versioning:** content keyed by `(intent_key, content_version, model_version)`; invalidation is lazy; a flagged item regenerates just itself.
---
## 11. MEMORY ENGINE
A **mastery-tracking engine**, not a knowledge graph:
- **Per-concept mastery score** updated from demonstrated production at checkpoints and from misconception tags, via Bayesian Knowledge Tracing or an Elo-style update.
- **Spaced-repetition schedule** per concept (SM-2-like) driving Daily Review.
This drives difficulty scaling, review timing, reinforcement, and the mastery gate. A relational knowledge graph is deferred until there is data to justify it.
---
## 12. PEDAGOGY
The design implements the mechanics behind the **2-sigma** finding (1:1 tutoring + mastery learning outperforms classroom instruction by ~two standard deviations) — exactly what generative AI can deliver and a feed structurally cannot:
- **Active production / retrieval practice** at every checkpoint.
- **Mastery gating** before advancement.
- **Spaced repetition** via Daily Review.
- **Interleaving** of related concepts rather than blocking.
---
## 13. PERSONALIZATION
- **Variant selection** by mastery: pick the pre-generated difficulty variant matching the learner's score. Deterministic, sub-millisecond, zero live LLM cost.
- **Live personalization** is the feedback and re-explanation themselves — which is the core value, not overhead, and is where live tokens are deliberately spent.
---
## 14. SAFETY & QUALITY CONTROL
Three strictness modes — **open**, **controlled** (default), **enterprise-safe** — applied as parameters to generation, content verification, and response grading (including how off-topic or unsafe free-text input is handled). Filtered domains are enforced at intent normalization and at T0/T1.
---
## 15. TELEMETRY & EVALUATION
Because the product *is* diagnosis, instrumentation is a first-class requirement, not an afterthought:
- **Diagnosis telemetry:** every `response → grade` is logged; track false-fail rate, misconception-hit rate, and mastery gain per concept. You cannot improve a diagnosis engine you don't measure.
- **LLM observability:** per-call tracing of latency, tokens, and cost (Langfuse or OpenTelemetry). Without it the false-fail and cost metrics are aspirational.
- **Eval harness in CI:** golden sets for content verification and response grading run on every prompt change. Prompts are code — versioned and tested. The harness doubles as the build's definition of "good enough."
- **Cost/latency guards:** per-session token cap (the Lesson bound enforced in code), a provider circuit breaker, an **edge rate limiter on the cold-start generation endpoint** (it spends money on demand), and graceful degradation to abstain→reveal.
---
## 16. FEATURE SET
**Core (in the product):** intent entry; Journey mode; bounded Lessons with checkpoints; live diagnosis with abstention; mastery + spaced repetition; Daily Review; mastery map (learner-facing view of mastered / shaky / due); source citations (surfacing the provenance already stored).
**Loop reinforcers (near-term):** confidence rating before reveal (catches *confidently wrong*); Socratic follow-up probe on a `partial` verdict.
**Deferred:** learn from your own material (PDF/notes → personalized Journey — strong differentiator, leverages existing RAG infra, but a large surface); voice.
**Explicitly out of scope:** XP / badges / leaderboards, infinite feed, social features, multi-provider fallback orchestration, knowledge graph. Microlearning + Daily Review + mastery map supply motivation intrinsically; bolt-on gamification risks reintroducing passive, low-retention "junk-food" learning.
---
## 17. DATA MODEL
```
source_chunk(id, doc_ref, embedding, text)
blueprint(id, intent_key, embedding, title, model_version, content_version,
status[draft|verifying|published|flagged])
concept(id, blueprint_id, ord, name, retrieval_ctx_ref)
lesson(id, blueprint_id, ord, est_minutes)
segment(id, lesson_id, ord, body_json, source_chunk_ids[], verify_status, content_version)
checkpoint(id, segment_id, prompt, kind[predict|explain|solve],
reference_answer, rubric_json, verify_status)
misconception(id, concept_id, tag, signature, verify_status)
verify_report(id, target_id, target_type, verdict, confidence, payload_json, model_version)
user(id, ...)
journey_instance(id, user_id, blueprint_id, position)
mastery(user_id, concept_id, score, last_seen, next_review)
response(id, user_id, checkpoint_id, text, ts)
grade(id, response_id, verdict, misconception_tag, confidence, payload_json)
job(id, type, idempotency_key, status, payload_json, attempts, created_at) -- durable ledger for paid background work
-- Redis: buffer:{session} -> [hydrated lesson/segment ids]; generation-concurrency locks
```
---
## 18. TECH STACK
**Single language, end to end (TypeScript).** One type system, one toolchain, and request/response/LLM contracts shared across the boundary. Nothing here requires a Python ML runtime — the work is hosted-LLM calls, embeddings, and pgvector queries, all first-class in TypeScript.
- **App framework:** Next.js (App Router, TypeScript) full-stack — React Server Components for the reading surface, Route Handlers for the API, native streaming/SSE for skeleton-first delivery. Split out a thin service (e.g. Hono) only if the API outgrows Next.
- **LLM orchestration:** Vercel AI SDK — provider-agnostic, native streaming and **schema-constrained structured output** (Zod). Generator and grader configured to **different providers/models** (decorrelation). Exploit two cost levers: native structured output for verifier/grader contracts, and **prompt caching** for the large, session-reused grounding context (sources + rubric + misconception library).
- **Contracts/validation:** Zod, shared across API I/O, LLM outputs, and DB-derived types.
- **Database:** PostgreSQL + pgvector (single store, source of truth).
- **ORM & migrations:** Drizzle ORM + drizzle-kit (type-safe, SQL-first, clean pgvector support).
- **Cache / buffer:** Redis — a cache and serve-buffer in *front* of Postgres, never load-bearing for availability.
- **Background work:** for the vertical slice, Route Handlers + the `job` ledger table suffice. When scale work arrives: **BullMQ** (Redis-backed, TS), with **idempotency keys and durable Postgres job state** — these jobs spend money, so a retry must never regenerate-and-rebill.
- **Styling & motion:** Tailwind CSS + CSS custom properties for design tokens; motion via CSS transitions and the **View Transitions API** (no animation library). `prefers-reduced-motion` respected.
- **Observability:** Langfuse (or OpenTelemetry) for LLM call tracing; standard app metrics/logs.
- **Rate limiting:** edge limiter (e.g. Upstash Ratelimit on Redis) on the cold-start generation endpoint.
- **Testing:** Vitest (unit; LLM mocked) + a golden-eval runner (real-model, CI-gated) + Playwright for the core learn-flow e2e.
- **Hosting:** Next on Vercel (or Node host); managed Postgres+pgvector (Neon / Supabase / RDS) and managed Redis (Upstash). Keep the cold-start generation path on a runtime with adequate timeout (Node serverless/long-running, not edge).
---
## 19. COST MODEL
- **Scaffolding** (explanations, checkpoints, rubrics, misconception libraries) is generated and T2-verified **once per blueprint**, amortized to near-zero per serve.
- **Live cost** is grading + feedback turns: per Lesson ≈ 24 checkpoints × (grade + feedback) + occasional re-explanation/tangent. The ~10-minute Lesson bound **directly caps live turns per session** — microlearning bounds cost as well as attention.
- **Levers:** a grading **cascade** (cheap model for the rubric-matched majority, strong model only on `novel`/`uncertain`) and **prompt caching** of the reused grounding context keep per-turn cost low.
- Net: with a high cache-hit rate, live LLM spend concentrates on cold long-tail topics (throttleable) and on the irreplaceable act of responding to the learner. Model this against the chosen provider's pricing at assumed hit rates and checkpoint counts before building.
---
## 20. MVP SCOPE & BUILD ROADMAP
**The MVP answers one question:** *can an AI tutor diagnose what a learner misunderstands and fix it, well enough that they learn and return?*
**MVP loop:** prompt → one Lesson (23 segments) → read + open-text checkpoints → live grading against verified rubric + misconception library → mastery gate.
**Phases:**
- **P0 — Scaffold & infra.** Next.js + TypeScript + Tailwind app; docker-compose (Postgres+pgvector, Redis); Drizzle schema + migrations; Vitest; provider-agnostic LLM layer (Vercel AI SDK) with a mockable client; design tokens stubbed.
- **P1 — Vertical slice (single hardcoded topic).** Content schema + seeded corpus + pgvector retrieval → RAG-grounded Lesson generation (segments, checkpoints, reference answers, rubrics) → serve + render the reading surface and checkpoint → **response grading with abstention****golden eval harness gating false-fail rate in CI.** Build the marginalia feedback moment here — it is the core experience. This milestone validates diagnosis quality.
- **P2 — Diagnosis depth.** Misconception-library quality + the full content-verification cascade.
- **P3 — Adaptivity & scale.** Mastery + spaced repetition; blueprint cache, intent normalization, BullMQ workers with idempotent paid jobs.
- **P4 — Roadmap features**, one at a time: confidence rating, Socratic probe, Daily Review, mastery map, source citations, variant personalization, accounts.
Anonymous-session-first; accounts arrive in P4.
---
## 21. OPEN DECISIONS
1. **Checkpoint count per Lesson** — fixed (e.g., 3) or adaptive to mastery? Sets the cost ceiling and the est-minutes contract.
2. **False-fail tolerance** — how generous is the grader, and when does it abstain vs. reveal? Sets the trust/rigor balance; prototype with real responses. *Settle before writing the grader.*
3. **Cold-start consistency** — may a cold long-tail Lesson be graded against a T1-only rubric (full T2 async), or does first serve block on T2?
4. **Concept granularity** — one concept per Lesson, or per Lesson-cluster?
5. **Source corpus policy** — define allowed, non-infringing sources before generation begins.
---
## 22. NORTH STAR
A learner types *"Teach me astrophysics."* A short, readable Lesson opens — finishable in ten minutes, set in calm, beautiful type. They read a few hundred words, then are asked to predict what happens to a collapsing star. They type a guess. The tutor reads it, pauses for a beat, and writes back in the margin — pointing at the exact phrase where their thinking went wrong, explaining the missing piece a different way, and asking again. They get it. Mastery updates; the next review is scheduled. Every explanation, problem, and rubric was generated and verified once and reused — only the part that read their actual answer ran live, which is the whole point.
+180
View File
@@ -0,0 +1,180 @@
# User Management Plan (T1 + T2 + T3)
## Context
Curio is anonymous-session-first (sessionStorage UUID). Adding Auth.js v5 (NextAuth) with email/password + OAuth (Google, GitHub), anonymous→auth migration, profile/settings, and admin panel. `users` table exists (id, createdAt) — needs column additions + 3 new Auth.js tables.
---
## Stack additions
```
next-auth@beta # Auth.js v5
@auth/drizzle-adapter # Drizzle adapter for Auth.js
bcryptjs + @types/bcryptjs # Password hashing
nodemailer + @types/nodemailer # Email verification
```
---
## Phase 1 — Schema migration
New migration via drizzle-kit generate → `drizzle/migrations/0004_auth.sql`
**Alter `user` table** — add columns:
- `email` varchar(255) unique nullable (nullable: existing anonymous rows have none)
- `email_verified` timestamp nullable
- `name` varchar(255) nullable
- `image` text nullable
- `role` varchar(20) not null default `'learner'` — values: `learner | admin | suspended`
- `password_hash` text nullable (null for OAuth users)
**New tables** (Auth.js Drizzle adapter required):
- `account` — OAuth provider links (userId FK → user.id, provider, providerAccountId, etc.)
- `session` — DB sessions (sessionToken PK, userId FK, expires)
- `verification_token` — email verification (identifier + token composite PK, expires)
Schema file: `src/lib/db/schema.ts` — add 3 new tables, extend users inline.
---
## Phase 2 — Auth.js core config
**`src/auth.ts`** — single source of truth:
- Adapter: `DrizzleAdapter(db, { usersTable: users, ... })`
- Session strategy: `database` (allows session revocation for admin ban)
- Providers: `Credentials` (email+bcrypt), `Google`, `GitHub`
- Callbacks: `session` + `jwt` embed `user.id` + `user.role`
- Pages: `signIn: '/auth/login'`, `error: '/auth/error'`
- Exports: `{ handlers, auth, signIn, signOut }`
**`src/app/api/auth/[...nextauth]/route.ts`** — `export const { GET, POST } = handlers`
**`src/lib/auth-helpers.ts`** — server utilities:
- `requireAuth()` — redirects to `/auth/login` if no session
- `requireAdmin()` — requireAuth + role check
- `getOptionalSession()` — returns session or null
---
## Phase 3 — Middleware
**`src/middleware.ts`**:
- Protected prefixes: `/profile`, `/settings`, `/admin`
- Redirect unauthenticated → `/auth/login?callbackUrl=...`
- `/admin` prefix: redirect non-admin → `/`
- All other paths pass through
---
## Phase 4 — Auth UI pages
`src/app/auth/login/page.tsx` — email+password form + Google/GitHub buttons + link to register
`src/app/auth/register/page.tsx` — email + name + password + confirm; POST `/api/auth/register`; then signIn + migrate anonymous session
`src/app/auth/verify-email/page.tsx` — "check inbox" + token handler
`src/app/auth/error/page.tsx` — maps Auth.js error codes to readable messages
Design: ink & light tokens, no card shadows, no rounded-xl. Labels above inputs.
---
## Phase 5 — Anonymous session migration
**`src/app/api/auth/migrate-session/route.ts`** — POST `{ anonUserId }`:
- Auth-gated (requires valid session)
- Transfers all rows: `responses`, `mastery`, `journey_instance` — UPDATE user_id = auth WHERE user_id = anon
- Renames Redis budget key `budget:{anon}``budget:{auth}`
- Idempotent
**Client side**: after `signIn` resolves, read `sessionStorage.getItem('curio_uid')`, POST migrate, clear key.
---
## Phase 6 — Update existing API routes
All 12 routes: prefer server session, fall back to client-supplied userId (keeps anonymous flow):
```typescript
const session = await getOptionalSession();
const userId = session?.user?.id ?? body.userId ?? searchParams.get('userId');
```
Routes with body userId: `/api/grade`, `/api/advance`, `/api/explain`, `/api/tangent`, `/api/socratic`
Routes with query userId: `/api/mastery`, `/api/review`
---
## Phase 7 — Learner account pages (T2)
**`src/app/profile/page.tsx`** (RSC, protected) — name, email, joined date; mastery overview per blueprint
**`src/app/settings/page.tsx`** (RSC, protected) — Profile section (name, avatar), Security (change password, linked OAuth accounts), Danger (delete account)
New API routes (all auth-gated):
- `PATCH /api/user/profile` — update name/image
- `POST /api/user/change-password` — verify old bcrypt → hash new
- `DELETE /api/user/account` — delete + cascade
---
## Phase 8 — Admin panel (T3)
All protected by `requireAdmin()`.
**`src/app/admin/layout.tsx`** — sidebar: Users | Reports | Blueprints
**`src/app/admin/page.tsx`** — stats: total users, active today
`GET /api/admin/stats`
**`src/app/admin/users/page.tsx`** — paginated user list; actions: promote to admin, suspend, delete
`GET /api/admin/users`, `PATCH /api/admin/users/[id]`, `DELETE /api/admin/users/[id]`
**`src/app/admin/reports/page.tsx`** — content_report triage; actions: dismiss, flag for re-verify
`GET /api/admin/reports`, `PATCH /api/admin/reports/[id]`
**`src/app/admin/blueprints/page.tsx`** — blueprint status management
`GET /api/admin/blueprints`, `PATCH /api/admin/blueprints/[id]`
---
## New env vars
```
AUTH_SECRET= # openssl rand -hex 32
AUTH_URL=http://localhost:3000
AUTH_GOOGLE_ID=
AUTH_GOOGLE_SECRET=
AUTH_GITHUB_ID=
AUTH_GITHUB_SECRET=
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
EMAIL_FROM=noreply@yourdomain.com
```
---
## Files created / modified (summary)
**New:** `src/auth.ts`, `src/middleware.ts`, `src/lib/auth-helpers.ts`, `src/app/api/auth/[...nextauth]/route.ts`, `src/app/api/auth/register/route.ts`, `src/app/api/auth/migrate-session/route.ts`, `src/app/auth/login|register|verify-email|error/page.tsx`, `src/app/profile/page.tsx`, `src/app/settings/page.tsx`, `src/app/api/user/profile|change-password|account/route.ts`, `src/app/admin/layout|page.tsx`, `src/app/admin/users|reports|blueprints/page.tsx`, `src/app/api/admin/stats|users/[id]|reports/[id]|blueprints/[id]/route.ts`
**Modified:** `src/lib/db/schema.ts` (extend users + 3 tables), all 12 existing API routes (userId fallback), `src/app/page.tsx` (nav link), `src/app/layout.tsx` (SessionProvider), `.env.example`
---
## Verification
```bash
pnpm drizzle-kit generate && pnpm drizzle-kit migrate # 0004_auth applies clean
pnpm dev
curl http://localhost:3000/api/auth/providers # returns Google, GitHub, credentials
# Register → lesson → grade works (anonymous flow intact)
# Register → verify email → profile shows mastery
# Set role='admin' in DB → /admin accessible
# Non-admin → /admin redirects to /
pnpm test # existing suite passes (userId fallback preserves behavior)
```
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/lib/db/schema.ts',
out: './drizzle/migrations',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL ?? 'postgresql://curio:curio@localhost:5433/curio',
},
});
@@ -0,0 +1,140 @@
CREATE TYPE "public"."blueprint_status" AS ENUM('draft', 'verifying', 'published', 'flagged');--> statement-breakpoint
CREATE TYPE "public"."checkpoint_kind" AS ENUM('predict', 'explain', 'solve');--> statement-breakpoint
CREATE TYPE "public"."grade_verdict" AS ENUM('mastered', 'partial', 'misconception', 'uncertain');--> statement-breakpoint
CREATE TYPE "public"."job_status" AS ENUM('pending', 'running', 'done', 'failed');--> statement-breakpoint
CREATE TYPE "public"."verify_status" AS ENUM('pending', 'pass', 'fail', 'uncertain');--> statement-breakpoint
CREATE TYPE "public"."verify_verdict" AS ENUM('pass', 'fail', 'uncertain');--> statement-breakpoint
CREATE TABLE "blueprint" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"intent_key" text NOT NULL,
"embedding" vector(1536),
"title" text NOT NULL,
"model_version" text NOT NULL,
"content_version" integer DEFAULT 1 NOT NULL,
"status" "blueprint_status" DEFAULT 'draft' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "blueprint_intent_key_unique" UNIQUE("intent_key")
);
--> statement-breakpoint
CREATE TABLE "checkpoint" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"segment_id" uuid NOT NULL,
"prompt" text NOT NULL,
"kind" "checkpoint_kind" NOT NULL,
"reference_answer" text NOT NULL,
"rubric_json" jsonb NOT NULL,
"verify_status" "verify_status" DEFAULT 'pending' NOT NULL
);
--> statement-breakpoint
CREATE TABLE "concept" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"blueprint_id" uuid NOT NULL,
"ord" integer NOT NULL,
"name" text NOT NULL,
"retrieval_ctx_ref" text
);
--> statement-breakpoint
CREATE TABLE "grade" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"response_id" uuid NOT NULL,
"verdict" "grade_verdict" NOT NULL,
"misconception_tag" text,
"confidence" real NOT NULL,
"payload_json" jsonb NOT NULL
);
--> statement-breakpoint
CREATE TABLE "job" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"type" text NOT NULL,
"idempotency_key" text NOT NULL,
"status" "job_status" DEFAULT 'pending' NOT NULL,
"payload_json" jsonb NOT NULL,
"attempts" integer DEFAULT 0 NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "job_idempotency_key_unique" UNIQUE("idempotency_key")
);
--> statement-breakpoint
CREATE TABLE "journey_instance" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"blueprint_id" uuid NOT NULL,
"position" integer DEFAULT 0 NOT NULL
);
--> statement-breakpoint
CREATE TABLE "lesson" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"blueprint_id" uuid NOT NULL,
"ord" integer NOT NULL,
"est_minutes" integer DEFAULT 10 NOT NULL
);
--> statement-breakpoint
CREATE TABLE "mastery" (
"user_id" uuid NOT NULL,
"concept_id" uuid NOT NULL,
"score" real DEFAULT 0.5 NOT NULL,
"last_seen" timestamp DEFAULT now() NOT NULL,
"next_review" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "mastery_user_id_concept_id_pk" PRIMARY KEY("user_id","concept_id")
);
--> statement-breakpoint
CREATE TABLE "misconception" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"concept_id" uuid NOT NULL,
"tag" text NOT NULL,
"signature" text NOT NULL,
"verify_status" "verify_status" DEFAULT 'pending' NOT NULL
);
--> statement-breakpoint
CREATE TABLE "response" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"checkpoint_id" uuid NOT NULL,
"text" text NOT NULL,
"ts" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "segment" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lesson_id" uuid NOT NULL,
"ord" integer NOT NULL,
"body_json" jsonb NOT NULL,
"source_chunk_ids" uuid[] DEFAULT '{}' NOT NULL,
"verify_status" "verify_status" DEFAULT 'pending' NOT NULL,
"content_version" integer DEFAULT 1 NOT NULL
);
--> statement-breakpoint
CREATE TABLE "source_chunk" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"doc_ref" text NOT NULL,
"embedding" vector(1536),
"text" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "user" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "verify_report" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"target_id" uuid NOT NULL,
"target_type" text NOT NULL,
"verdict" "verify_verdict" NOT NULL,
"confidence" real NOT NULL,
"payload_json" jsonb NOT NULL,
"model_version" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "checkpoint" ADD CONSTRAINT "checkpoint_segment_id_segment_id_fk" FOREIGN KEY ("segment_id") REFERENCES "public"."segment"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "concept" ADD CONSTRAINT "concept_blueprint_id_blueprint_id_fk" FOREIGN KEY ("blueprint_id") REFERENCES "public"."blueprint"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "grade" ADD CONSTRAINT "grade_response_id_response_id_fk" FOREIGN KEY ("response_id") REFERENCES "public"."response"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "journey_instance" ADD CONSTRAINT "journey_instance_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "journey_instance" ADD CONSTRAINT "journey_instance_blueprint_id_blueprint_id_fk" FOREIGN KEY ("blueprint_id") REFERENCES "public"."blueprint"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "lesson" ADD CONSTRAINT "lesson_blueprint_id_blueprint_id_fk" FOREIGN KEY ("blueprint_id") REFERENCES "public"."blueprint"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "mastery" ADD CONSTRAINT "mastery_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "mastery" ADD CONSTRAINT "mastery_concept_id_concept_id_fk" FOREIGN KEY ("concept_id") REFERENCES "public"."concept"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "misconception" ADD CONSTRAINT "misconception_concept_id_concept_id_fk" FOREIGN KEY ("concept_id") REFERENCES "public"."concept"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "response" ADD CONSTRAINT "response_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "response" ADD CONSTRAINT "response_checkpoint_id_checkpoint_id_fk" FOREIGN KEY ("checkpoint_id") REFERENCES "public"."checkpoint"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "segment" ADD CONSTRAINT "segment_lesson_id_lesson_id_fk" FOREIGN KEY ("lesson_id") REFERENCES "public"."lesson"("id") ON DELETE no action ON UPDATE no action;
@@ -0,0 +1,2 @@
ALTER TABLE "misconception" ADD COLUMN "description" text;--> statement-breakpoint
ALTER TABLE "misconception" ADD COLUMN "correction" text;
@@ -0,0 +1 @@
ALTER TABLE "response" ADD COLUMN "self_confidence" smallint;
@@ -0,0 +1,26 @@
CREATE TYPE "public"."content_report_reason" AS ENUM('grade_wrong', 'content_error', 'other');--> statement-breakpoint
CREATE TABLE "content_report" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"checkpoint_id" uuid NOT NULL,
"grade_id" uuid,
"reason" "content_report_reason" NOT NULL,
"notes" text,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "novel_misconduct_queue" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"grade_id" uuid NOT NULL,
"checkpoint_id" uuid NOT NULL,
"response_text" text NOT NULL,
"evidence_span" text NOT NULL,
"processed" boolean DEFAULT false NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "checkpoint" ADD COLUMN "content_version" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
ALTER TABLE "segment" ADD COLUMN "difficulty_level" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
ALTER TABLE "content_report" ADD CONSTRAINT "content_report_checkpoint_id_checkpoint_id_fk" FOREIGN KEY ("checkpoint_id") REFERENCES "public"."checkpoint"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "content_report" ADD CONSTRAINT "content_report_grade_id_grade_id_fk" FOREIGN KEY ("grade_id") REFERENCES "public"."grade"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "novel_misconduct_queue" ADD CONSTRAINT "novel_misconduct_queue_grade_id_grade_id_fk" FOREIGN KEY ("grade_id") REFERENCES "public"."grade"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "novel_misconduct_queue" ADD CONSTRAINT "novel_misconduct_queue_checkpoint_id_checkpoint_id_fk" FOREIGN KEY ("checkpoint_id") REFERENCES "public"."checkpoint"("id") ON DELETE no action ON UPDATE no action;
+38
View File
@@ -0,0 +1,38 @@
CREATE TYPE "public"."user_role" AS ENUM('learner', 'admin', 'suspended');--> statement-breakpoint
CREATE TABLE "account" (
"user_id" uuid NOT NULL,
"type" text NOT NULL,
"provider" text NOT NULL,
"provider_account_id" text NOT NULL,
"refresh_token" text,
"access_token" text,
"expires_at" integer,
"token_type" text,
"scope" text,
"id_token" text,
"session_state" text,
CONSTRAINT "account_provider_provider_account_id_pk" PRIMARY KEY("provider","provider_account_id")
);
--> statement-breakpoint
CREATE TABLE "session" (
"session_token" text PRIMARY KEY NOT NULL,
"user_id" uuid NOT NULL,
"expires" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE "verification_token" (
"identifier" text NOT NULL,
"token" text NOT NULL,
"expires" timestamp NOT NULL,
CONSTRAINT "verification_token_identifier_token_pk" PRIMARY KEY("identifier","token")
);
--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "email" varchar(255);--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "email_verified" timestamp;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "name" varchar(255);--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "image" text;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "role" "user_role" DEFAULT 'learner' NOT NULL;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "password_hash" text;--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user" ADD CONSTRAINT "user_email_unique" UNIQUE("email");
+13
View File
@@ -0,0 +1,13 @@
CREATE TABLE "password_reset_token" (
"token" text PRIMARY KEY NOT NULL,
"user_id" uuid NOT NULL,
"expires" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE "site_config" (
"key" varchar(100) PRIMARY KEY NOT NULL,
"value" text NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "password_reset_token" ADD CONSTRAINT "password_reset_token_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
+951
View File
@@ -0,0 +1,951 @@
{
"id": "99287306-1d2d-4763-a7fa-4767e0247728",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.blueprint": {
"name": "blueprint",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"intent_key": {
"name": "intent_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"embedding": {
"name": "embedding",
"type": "vector(1536)",
"primaryKey": false,
"notNull": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"model_version": {
"name": "model_version",
"type": "text",
"primaryKey": false,
"notNull": true
},
"content_version": {
"name": "content_version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
},
"status": {
"name": "status",
"type": "blueprint_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'draft'"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"blueprint_intent_key_unique": {
"name": "blueprint_intent_key_unique",
"nullsNotDistinct": false,
"columns": [
"intent_key"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.checkpoint": {
"name": "checkpoint",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"segment_id": {
"name": "segment_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"prompt": {
"name": "prompt",
"type": "text",
"primaryKey": false,
"notNull": true
},
"kind": {
"name": "kind",
"type": "checkpoint_kind",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"reference_answer": {
"name": "reference_answer",
"type": "text",
"primaryKey": false,
"notNull": true
},
"rubric_json": {
"name": "rubric_json",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"verify_status": {
"name": "verify_status",
"type": "verify_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
}
},
"indexes": {},
"foreignKeys": {
"checkpoint_segment_id_segment_id_fk": {
"name": "checkpoint_segment_id_segment_id_fk",
"tableFrom": "checkpoint",
"tableTo": "segment",
"columnsFrom": [
"segment_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.concept": {
"name": "concept",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"blueprint_id": {
"name": "blueprint_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"ord": {
"name": "ord",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"retrieval_ctx_ref": {
"name": "retrieval_ctx_ref",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"concept_blueprint_id_blueprint_id_fk": {
"name": "concept_blueprint_id_blueprint_id_fk",
"tableFrom": "concept",
"tableTo": "blueprint",
"columnsFrom": [
"blueprint_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.grade": {
"name": "grade",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"response_id": {
"name": "response_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"verdict": {
"name": "verdict",
"type": "grade_verdict",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"misconception_tag": {
"name": "misconception_tag",
"type": "text",
"primaryKey": false,
"notNull": false
},
"confidence": {
"name": "confidence",
"type": "real",
"primaryKey": false,
"notNull": true
},
"payload_json": {
"name": "payload_json",
"type": "jsonb",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"grade_response_id_response_id_fk": {
"name": "grade_response_id_response_id_fk",
"tableFrom": "grade",
"tableTo": "response",
"columnsFrom": [
"response_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.job": {
"name": "job",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"idempotency_key": {
"name": "idempotency_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "job_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
},
"payload_json": {
"name": "payload_json",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"attempts": {
"name": "attempts",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"job_idempotency_key_unique": {
"name": "job_idempotency_key_unique",
"nullsNotDistinct": false,
"columns": [
"idempotency_key"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.journey_instance": {
"name": "journey_instance",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"blueprint_id": {
"name": "blueprint_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"journey_instance_user_id_user_id_fk": {
"name": "journey_instance_user_id_user_id_fk",
"tableFrom": "journey_instance",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"journey_instance_blueprint_id_blueprint_id_fk": {
"name": "journey_instance_blueprint_id_blueprint_id_fk",
"tableFrom": "journey_instance",
"tableTo": "blueprint",
"columnsFrom": [
"blueprint_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.lesson": {
"name": "lesson",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"blueprint_id": {
"name": "blueprint_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"ord": {
"name": "ord",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"est_minutes": {
"name": "est_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 10
}
},
"indexes": {},
"foreignKeys": {
"lesson_blueprint_id_blueprint_id_fk": {
"name": "lesson_blueprint_id_blueprint_id_fk",
"tableFrom": "lesson",
"tableTo": "blueprint",
"columnsFrom": [
"blueprint_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.mastery": {
"name": "mastery",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"concept_id": {
"name": "concept_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"score": {
"name": "score",
"type": "real",
"primaryKey": false,
"notNull": true,
"default": 0.5
},
"last_seen": {
"name": "last_seen",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"next_review": {
"name": "next_review",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"mastery_user_id_user_id_fk": {
"name": "mastery_user_id_user_id_fk",
"tableFrom": "mastery",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"mastery_concept_id_concept_id_fk": {
"name": "mastery_concept_id_concept_id_fk",
"tableFrom": "mastery",
"tableTo": "concept",
"columnsFrom": [
"concept_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"mastery_user_id_concept_id_pk": {
"name": "mastery_user_id_concept_id_pk",
"columns": [
"user_id",
"concept_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.misconception": {
"name": "misconception",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"concept_id": {
"name": "concept_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"tag": {
"name": "tag",
"type": "text",
"primaryKey": false,
"notNull": true
},
"signature": {
"name": "signature",
"type": "text",
"primaryKey": false,
"notNull": true
},
"verify_status": {
"name": "verify_status",
"type": "verify_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
}
},
"indexes": {},
"foreignKeys": {
"misconception_concept_id_concept_id_fk": {
"name": "misconception_concept_id_concept_id_fk",
"tableFrom": "misconception",
"tableTo": "concept",
"columnsFrom": [
"concept_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.response": {
"name": "response",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"checkpoint_id": {
"name": "checkpoint_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"text": {
"name": "text",
"type": "text",
"primaryKey": false,
"notNull": true
},
"ts": {
"name": "ts",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"response_user_id_user_id_fk": {
"name": "response_user_id_user_id_fk",
"tableFrom": "response",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"response_checkpoint_id_checkpoint_id_fk": {
"name": "response_checkpoint_id_checkpoint_id_fk",
"tableFrom": "response",
"tableTo": "checkpoint",
"columnsFrom": [
"checkpoint_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.segment": {
"name": "segment",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"lesson_id": {
"name": "lesson_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"ord": {
"name": "ord",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"body_json": {
"name": "body_json",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"source_chunk_ids": {
"name": "source_chunk_ids",
"type": "uuid[]",
"primaryKey": false,
"notNull": true,
"default": "'{}'"
},
"verify_status": {
"name": "verify_status",
"type": "verify_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
},
"content_version": {
"name": "content_version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
}
},
"indexes": {},
"foreignKeys": {
"segment_lesson_id_lesson_id_fk": {
"name": "segment_lesson_id_lesson_id_fk",
"tableFrom": "segment",
"tableTo": "lesson",
"columnsFrom": [
"lesson_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.source_chunk": {
"name": "source_chunk",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"doc_ref": {
"name": "doc_ref",
"type": "text",
"primaryKey": false,
"notNull": true
},
"embedding": {
"name": "embedding",
"type": "vector(1536)",
"primaryKey": false,
"notNull": false
},
"text": {
"name": "text",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verify_report": {
"name": "verify_report",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"target_id": {
"name": "target_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"target_type": {
"name": "target_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"verdict": {
"name": "verdict",
"type": "verify_verdict",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"confidence": {
"name": "confidence",
"type": "real",
"primaryKey": false,
"notNull": true
},
"payload_json": {
"name": "payload_json",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"model_version": {
"name": "model_version",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.blueprint_status": {
"name": "blueprint_status",
"schema": "public",
"values": [
"draft",
"verifying",
"published",
"flagged"
]
},
"public.checkpoint_kind": {
"name": "checkpoint_kind",
"schema": "public",
"values": [
"predict",
"explain",
"solve"
]
},
"public.grade_verdict": {
"name": "grade_verdict",
"schema": "public",
"values": [
"mastered",
"partial",
"misconception",
"uncertain"
]
},
"public.job_status": {
"name": "job_status",
"schema": "public",
"values": [
"pending",
"running",
"done",
"failed"
]
},
"public.verify_status": {
"name": "verify_status",
"schema": "public",
"values": [
"pending",
"pass",
"fail",
"uncertain"
]
},
"public.verify_verdict": {
"name": "verify_verdict",
"schema": "public",
"values": [
"pass",
"fail",
"uncertain"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+963
View File
@@ -0,0 +1,963 @@
{
"id": "c96c484c-a142-4f76-a53e-f99a3f8688b2",
"prevId": "99287306-1d2d-4763-a7fa-4767e0247728",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.blueprint": {
"name": "blueprint",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"intent_key": {
"name": "intent_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"embedding": {
"name": "embedding",
"type": "vector(1536)",
"primaryKey": false,
"notNull": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"model_version": {
"name": "model_version",
"type": "text",
"primaryKey": false,
"notNull": true
},
"content_version": {
"name": "content_version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
},
"status": {
"name": "status",
"type": "blueprint_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'draft'"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"blueprint_intent_key_unique": {
"name": "blueprint_intent_key_unique",
"nullsNotDistinct": false,
"columns": [
"intent_key"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.checkpoint": {
"name": "checkpoint",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"segment_id": {
"name": "segment_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"prompt": {
"name": "prompt",
"type": "text",
"primaryKey": false,
"notNull": true
},
"kind": {
"name": "kind",
"type": "checkpoint_kind",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"reference_answer": {
"name": "reference_answer",
"type": "text",
"primaryKey": false,
"notNull": true
},
"rubric_json": {
"name": "rubric_json",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"verify_status": {
"name": "verify_status",
"type": "verify_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
}
},
"indexes": {},
"foreignKeys": {
"checkpoint_segment_id_segment_id_fk": {
"name": "checkpoint_segment_id_segment_id_fk",
"tableFrom": "checkpoint",
"tableTo": "segment",
"columnsFrom": [
"segment_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.concept": {
"name": "concept",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"blueprint_id": {
"name": "blueprint_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"ord": {
"name": "ord",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"retrieval_ctx_ref": {
"name": "retrieval_ctx_ref",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"concept_blueprint_id_blueprint_id_fk": {
"name": "concept_blueprint_id_blueprint_id_fk",
"tableFrom": "concept",
"tableTo": "blueprint",
"columnsFrom": [
"blueprint_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.grade": {
"name": "grade",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"response_id": {
"name": "response_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"verdict": {
"name": "verdict",
"type": "grade_verdict",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"misconception_tag": {
"name": "misconception_tag",
"type": "text",
"primaryKey": false,
"notNull": false
},
"confidence": {
"name": "confidence",
"type": "real",
"primaryKey": false,
"notNull": true
},
"payload_json": {
"name": "payload_json",
"type": "jsonb",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"grade_response_id_response_id_fk": {
"name": "grade_response_id_response_id_fk",
"tableFrom": "grade",
"tableTo": "response",
"columnsFrom": [
"response_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.job": {
"name": "job",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"idempotency_key": {
"name": "idempotency_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "job_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
},
"payload_json": {
"name": "payload_json",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"attempts": {
"name": "attempts",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"job_idempotency_key_unique": {
"name": "job_idempotency_key_unique",
"nullsNotDistinct": false,
"columns": [
"idempotency_key"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.journey_instance": {
"name": "journey_instance",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"blueprint_id": {
"name": "blueprint_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"journey_instance_user_id_user_id_fk": {
"name": "journey_instance_user_id_user_id_fk",
"tableFrom": "journey_instance",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"journey_instance_blueprint_id_blueprint_id_fk": {
"name": "journey_instance_blueprint_id_blueprint_id_fk",
"tableFrom": "journey_instance",
"tableTo": "blueprint",
"columnsFrom": [
"blueprint_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.lesson": {
"name": "lesson",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"blueprint_id": {
"name": "blueprint_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"ord": {
"name": "ord",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"est_minutes": {
"name": "est_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 10
}
},
"indexes": {},
"foreignKeys": {
"lesson_blueprint_id_blueprint_id_fk": {
"name": "lesson_blueprint_id_blueprint_id_fk",
"tableFrom": "lesson",
"tableTo": "blueprint",
"columnsFrom": [
"blueprint_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.mastery": {
"name": "mastery",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"concept_id": {
"name": "concept_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"score": {
"name": "score",
"type": "real",
"primaryKey": false,
"notNull": true,
"default": 0.5
},
"last_seen": {
"name": "last_seen",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"next_review": {
"name": "next_review",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"mastery_user_id_user_id_fk": {
"name": "mastery_user_id_user_id_fk",
"tableFrom": "mastery",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"mastery_concept_id_concept_id_fk": {
"name": "mastery_concept_id_concept_id_fk",
"tableFrom": "mastery",
"tableTo": "concept",
"columnsFrom": [
"concept_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"mastery_user_id_concept_id_pk": {
"name": "mastery_user_id_concept_id_pk",
"columns": [
"user_id",
"concept_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.misconception": {
"name": "misconception",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"concept_id": {
"name": "concept_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"tag": {
"name": "tag",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"signature": {
"name": "signature",
"type": "text",
"primaryKey": false,
"notNull": true
},
"correction": {
"name": "correction",
"type": "text",
"primaryKey": false,
"notNull": false
},
"verify_status": {
"name": "verify_status",
"type": "verify_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
}
},
"indexes": {},
"foreignKeys": {
"misconception_concept_id_concept_id_fk": {
"name": "misconception_concept_id_concept_id_fk",
"tableFrom": "misconception",
"tableTo": "concept",
"columnsFrom": [
"concept_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.response": {
"name": "response",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"checkpoint_id": {
"name": "checkpoint_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"text": {
"name": "text",
"type": "text",
"primaryKey": false,
"notNull": true
},
"ts": {
"name": "ts",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"response_user_id_user_id_fk": {
"name": "response_user_id_user_id_fk",
"tableFrom": "response",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"response_checkpoint_id_checkpoint_id_fk": {
"name": "response_checkpoint_id_checkpoint_id_fk",
"tableFrom": "response",
"tableTo": "checkpoint",
"columnsFrom": [
"checkpoint_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.segment": {
"name": "segment",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"lesson_id": {
"name": "lesson_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"ord": {
"name": "ord",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"body_json": {
"name": "body_json",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"source_chunk_ids": {
"name": "source_chunk_ids",
"type": "uuid[]",
"primaryKey": false,
"notNull": true,
"default": "'{}'"
},
"verify_status": {
"name": "verify_status",
"type": "verify_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
},
"content_version": {
"name": "content_version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
}
},
"indexes": {},
"foreignKeys": {
"segment_lesson_id_lesson_id_fk": {
"name": "segment_lesson_id_lesson_id_fk",
"tableFrom": "segment",
"tableTo": "lesson",
"columnsFrom": [
"lesson_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.source_chunk": {
"name": "source_chunk",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"doc_ref": {
"name": "doc_ref",
"type": "text",
"primaryKey": false,
"notNull": true
},
"embedding": {
"name": "embedding",
"type": "vector(1536)",
"primaryKey": false,
"notNull": false
},
"text": {
"name": "text",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verify_report": {
"name": "verify_report",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"target_id": {
"name": "target_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"target_type": {
"name": "target_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"verdict": {
"name": "verdict",
"type": "verify_verdict",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"confidence": {
"name": "confidence",
"type": "real",
"primaryKey": false,
"notNull": true
},
"payload_json": {
"name": "payload_json",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"model_version": {
"name": "model_version",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.blueprint_status": {
"name": "blueprint_status",
"schema": "public",
"values": [
"draft",
"verifying",
"published",
"flagged"
]
},
"public.checkpoint_kind": {
"name": "checkpoint_kind",
"schema": "public",
"values": [
"predict",
"explain",
"solve"
]
},
"public.grade_verdict": {
"name": "grade_verdict",
"schema": "public",
"values": [
"mastered",
"partial",
"misconception",
"uncertain"
]
},
"public.job_status": {
"name": "job_status",
"schema": "public",
"values": [
"pending",
"running",
"done",
"failed"
]
},
"public.verify_status": {
"name": "verify_status",
"schema": "public",
"values": [
"pending",
"pass",
"fail",
"uncertain"
]
},
"public.verify_verdict": {
"name": "verify_verdict",
"schema": "public",
"values": [
"pass",
"fail",
"uncertain"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
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
+48
View File
@@ -0,0 +1,48 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1781991328126,
"tag": "0000_overrated_liz_osborn",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1782018031574,
"tag": "0001_marvelous_pepper_potts",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1782100000000,
"tag": "0002_response_confidence",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1782050126016,
"tag": "0003_parched_wither",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1782066514369,
"tag": "0004_auth",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1782070177922,
"tag": "0005_site_email",
"breakpoints": true
}
]
}
+124
View File
@@ -0,0 +1,124 @@
/**
* E2E: full learn flow.
*
* Runs against the live dev server with seeded data (pnpm seed).
* Mocks only the grade and socratic API calls (avoid real LLM cost).
* Lesson data comes from the DB (seeded js-closures corpus).
*
* Pre-condition: `docker compose up -d && pnpm seed` must have run.
*/
import { test, expect } from '@playwright/test';
// ── Mock payloads ─────────────────────────────────────────────────────────────
const MOCK_GRADE_MASTERED = {
responseId: 'resp-uuid-001',
gradeId: 'grade-uuid-001',
grade: {
verdict: 'mastered',
rubric_hits: ['r1', 'r2'],
misconception_tag: 'none',
evidence_span: 'retains access to variables from its enclosing scope',
feedback_directive: 'advance',
confidence: 0.92,
},
};
const MOCK_GRADE_PARTIAL = {
responseId: 'resp-uuid-002',
gradeId: 'grade-uuid-002',
grade: {
verdict: 'partial',
rubric_hits: ['r1'],
misconception_tag: 'none',
evidence_span: 'a function inside a function',
feedback_directive: 'probe: Can you say more about what happens after the outer function returns?',
confidence: 0.78,
},
};
const MOCK_GRADE_BUDGET_EXCEEDED = {
status: 429,
json: { error: 'Session limit reached. Start a new session to continue.' },
};
// ── Tests ─────────────────────────────────────────────────────────────────────
test.describe('learn flow (seeded DB)', () => {
test('homepage loads with intent input', async ({ page }) => {
await page.goto('/');
await expect(page.locator('h1')).toContainText('Curio');
await expect(page.locator('[aria-label="Learning intent"]')).toBeVisible();
await expect(page.locator('button[type="submit"]')).toBeDisabled();
});
test('intent submission navigates to lesson page', async ({ page }) => {
await page.route('/api/intent', (route) =>
route.fulfill({
json: { intentKey: 'javascript-closures', blueprintId: 'bp-seed', isNew: false },
}),
);
await page.goto('/');
await page.fill('[aria-label="Learning intent"]', 'JavaScript closures');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/learn/javascript-closures');
});
test('lesson page renders seeded content', async ({ page }) => {
await page.goto('/learn/javascript-closures');
// Blueprint title
await expect(page.locator('h1')).toContainText('JavaScript Closures');
// First segment body is visible
await expect(page.getByText(/closure/i).first()).toBeVisible();
// First checkpoint prompt is visible
await expect(page.getByText(/explain what a closure is/i)).toBeVisible();
});
test('mastered verdict unlocks next segment', async ({ page }) => {
await page.route('/api/grade', (route) => route.fulfill({ json: MOCK_GRADE_MASTERED }));
await page.goto('/learn/javascript-closures');
const textarea = page.locator('textarea').first();
await textarea.fill('A closure retains access to variables from its enclosing scope even after the outer function returns.');
await page.locator('button[type="submit"]').first().click();
await expect(page.getByText(/mastered/i)).toBeVisible();
// Second segment should now be unlocked (no longer shows "locked" hint)
await expect(page.getByText('Complete the previous checkpoint to continue.')).not.toBeVisible();
});
test('partial verdict shows probe question', async ({ page }) => {
await page.route('/api/grade', (route) => route.fulfill({ json: MOCK_GRADE_PARTIAL }));
await page.goto('/learn/javascript-closures');
const textarea = page.locator('textarea').first();
await textarea.fill('a function inside a function');
await page.locator('button[type="submit"]').first().click();
await expect(page.getByText(/partial/i)).toBeVisible();
await expect(
page.getByText(/what happens after the outer function returns/i),
).toBeVisible();
});
test('budget exceeded shows friendly error', async ({ page }) => {
await page.route('/api/grade', (route) =>
route.fulfill(MOCK_GRADE_BUDGET_EXCEEDED),
);
await page.goto('/learn/javascript-closures');
const textarea = page.locator('textarea').first();
await textarea.fill('some answer');
await page.locator('button[type="submit"]').first().click();
await expect(page.getByText(/session limit/i)).toBeVisible();
});
});
+14
View File
@@ -0,0 +1,14 @@
import { test, expect } from '@playwright/test';
test('homepage loads with intent input', async ({ page }) => {
await page.goto('/');
await expect(page.locator('h1')).toContainText('Curio');
await expect(page.locator('[aria-label="Learning intent"]')).toBeVisible();
await expect(page.locator('button[type="submit"]')).toBeDisabled();
});
test('submit button enables when input has text', async ({ page }) => {
await page.goto('/');
await page.fill('[aria-label="Learning intent"]', 'JavaScript closures');
await expect(page.locator('button[type="submit"]')).toBeEnabled();
});
+137
View File
@@ -0,0 +1,137 @@
/**
* BullMQ worker: generate lesson content for a blueprint.
*
* Idempotent: checks `job` ledger before generating.
* On success: writes lesson to Redis cache and updates job status.
* On failure: increments attempt count; sets status to 'failed' after processing.
*/
import { Worker } from 'bullmq';
import { eq } from 'drizzle-orm';
import { db } from '../src/lib/db';
import { jobs, blueprints, concepts, lessons } from '../src/lib/db/schema';
import { generateLesson } from '../src/lib/generation/generate-lesson';
import { verifyLesson } from '../src/lib/verification/verify-content';
import { getLessonResponse } from '../src/lib/db/queries';
import { setCachedLesson } from '../src/lib/cache/lesson';
import { enqueuePromoteBlueprint } from '../src/lib/jobs/queue';
import type { GenerateLessonJobData } from '../src/lib/jobs/queue';
const connection = {
url: process.env.REDIS_URL ?? 'redis://localhost:6379',
};
const worker = new Worker<GenerateLessonJobData>(
'generate-lesson',
async (job) => {
const { intentKey, blueprintId, idempotencyKey } = job.data;
// 1. Idempotency check — find the job row by idempotency key
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') {
// Already completed — warm cache if needed and return
const cached = await getLessonResponse(intentKey);
if (cached?.state === 'ready') {
await setCachedLesson(intentKey, cached);
}
return;
}
// Mark running
await db
.update(jobs)
.set({ status: 'running', attempts: (jobRow?.attempts ?? 0) + 1 })
.where(eq(jobs.id, idempotencyKey));
// 2. Check lesson doesn't already exist
const existing = await getLessonResponse(intentKey);
if (existing?.state === 'ready') {
await setCachedLesson(intentKey, existing);
await db.update(jobs).set({ status: 'done' }).where(eq(jobs.id, idempotencyKey));
return;
}
// 3. Fetch blueprint + concepts
const [blueprint] = await db
.select()
.from(blueprints)
.where(eq(blueprints.id, blueprintId))
.limit(1);
if (!blueprint) throw new Error(`Blueprint not found: ${blueprintId}`);
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. Generate lesson
const { lessonId } = await generateLesson({
blueprintId,
lessonOrd: 0,
topicTitle: blueprint.title,
conceptsToGenerate: conceptRows.map((c) => ({
id: c.id,
name: c.name,
retrievalCtxRef: c.retrievalCtxRef,
})),
});
// 5. T1 verification (non-blocking for serve — T2 + promotion handled by promote-blueprint job)
await verifyLesson({ lessonId, runT2: false });
// 6. Write to Redis cache
const lesson = await getLessonResponse(intentKey);
if (lesson?.state === 'ready') {
await setCachedLesson(intentKey, lesson);
}
// 7. Update job ledger
await db.update(jobs).set({ status: 'done' }).where(eq(jobs.id, idempotencyKey));
// 8. Enqueue blueprint promotion (T2 verify + misconceptions)
const promoteKey = `promote-blueprint:${blueprintId}:v1`;
const [existingPromote] = await db
.select({ id: jobs.id })
.from(jobs)
.where(eq(jobs.idempotencyKey, promoteKey))
.limit(1);
if (!existingPromote) {
const [promoteJobRow] = await db
.insert(jobs)
.values({
type: 'promote-blueprint',
idempotencyKey: promoteKey,
status: 'pending',
payloadJson: { blueprintId, intentKey, lessonId },
})
.returning({ id: jobs.id });
await enqueuePromoteBlueprint({
blueprintId,
intentKey,
idempotencyKey: promoteJobRow.id,
});
}
},
{ connection, concurrency: 2 },
);
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('[generate-lesson] job failed:', err);
});
export default worker;
+17
View File
@@ -0,0 +1,17 @@
import generateLessonWorker from './generate-lesson-job';
import promoteBlueprintWorker from './promote-blueprint-job';
console.log('[workers] Running: generate-lesson, promote-blueprint');
async function shutdown(signal: string) {
console.log(`[workers] ${signal} received — draining and closing workers...`);
await Promise.all([
generateLessonWorker.close(),
promoteBlueprintWorker.close(),
]);
console.log('[workers] Shutdown complete.');
process.exit(0);
}
process.on('SIGTERM', () => void shutdown('SIGTERM'));
process.on('SIGINT', () => void shutdown('SIGINT'));
+120
View File
@@ -0,0 +1,120 @@
/**
* BullMQ worker: T2 content verification + misconception generation + blueprint promotion.
*
* After T2 passes, generates difficulty level 2 and 3 variants (§8.1, §13) so the serve
* path can select the right level per learner without synchronous generation.
*
* Idempotent: checks job ledger before running, skips variant generation if segments
* at that level already exist.
*/
import { Worker } from 'bullmq';
import { eq, and } from 'drizzle-orm';
import { db } from '../src/lib/db';
import { jobs, concepts, lessons, segments, blueprints } from '../src/lib/db/schema';
import { verifyLesson } from '../src/lib/verification/verify-content';
import { generateMisconceptions } from '../src/lib/generation/generate-misconceptions';
import { verifyMisconceptions } from '../src/lib/verification/verify-misconceptions';
import { generateLesson } from '../src/lib/generation/generate-lesson';
import { invalidateCachedLesson } from '../src/lib/cache/lesson';
import type { PromoteBlueprintJobData } from '../src/lib/jobs/queue';
const connection = {
url: process.env.REDIS_URL ?? 'redis://localhost:6379',
};
const worker = new Worker<PromoteBlueprintJobData>(
'promote-blueprint',
async (job) => {
const { blueprintId, intentKey, idempotencyKey } = job.data;
// 1. Idempotency check
const [jobRow] = await db
.select({ id: jobs.id, status: jobs.status, payloadJson: jobs.payloadJson })
.from(jobs)
.where(eq(jobs.id, idempotencyKey))
.limit(1);
if (jobRow?.status === 'done') return;
await db
.update(jobs)
.set({ status: 'running', attempts: (jobRow as { attempts?: number })?.attempts ?? 1 })
.where(eq(jobs.id, idempotencyKey));
// 2. Find lesson for this blueprint
const [lesson] = await db
.select({ id: lessons.id })
.from(lessons)
.where(eq(lessons.blueprintId, blueprintId))
.limit(1);
if (!lesson) throw new Error(`No lesson for blueprint: ${blueprintId}`);
// 3. T2 verification (strong model — promotes blueprint to 'published' if all pass)
await verifyLesson({ lessonId: lesson.id, runT2: true });
// 4. Generate misconceptions for each concept
const conceptRows = await db
.select({ id: concepts.id })
.from(concepts)
.where(eq(concepts.blueprintId, blueprintId));
for (const concept of conceptRows) {
await generateMisconceptions({ conceptId: concept.id });
await verifyMisconceptions({ conceptId: concept.id });
}
// 5. Generate difficulty variants 2 and 3 (§8.1, §13)
// Each variant is a new set of segments at a higher explanation level.
// Skip if segments at that level already exist (idempotency).
const [blueprint] = await db
.select({ title: blueprints.title })
.from(blueprints)
.where(eq(blueprints.id, blueprintId))
.limit(1);
const conceptRowsFull = await db
.select({ id: concepts.id, name: concepts.name, retrievalCtxRef: concepts.retrievalCtxRef, ord: concepts.ord })
.from(concepts)
.where(eq(concepts.blueprintId, blueprintId));
if (blueprint && conceptRowsFull.length > 0) {
const lessonOrd = 0;
for (const level of [2, 3] as const) {
const existing = await db
.select({ id: segments.id })
.from(segments)
.where(and(eq(segments.lessonId, lesson.id), eq(segments.difficultyLevel, level)))
.limit(1);
if (existing.length === 0) {
await generateLesson({
blueprintId,
lessonOrd,
topicTitle: blueprint.title,
conceptsToGenerate: conceptRowsFull,
difficultyLevel: level,
});
}
}
}
// 6. Invalidate cache so next request fetches fresh lesson with updated verify status
await invalidateCachedLesson(intentKey);
await db.update(jobs).set({ status: 'done' }).where(eq(jobs.id, idempotencyKey));
},
{ 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('[promote-blueprint] job failed:', err);
});
export default worker;
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+5
View File
@@ -0,0 +1,5 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {};
export default nextConfig;
+75
View File
@@ -0,0 +1,75 @@
{
"name": "curio",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"format": "prettier --write .",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"drizzle-kit": "drizzle-kit",
"db:generate": "dotenv -e .env.local -- drizzle-kit generate",
"db:migrate": "dotenv -e .env.local -- drizzle-kit migrate",
"db:studio": "dotenv -e .env.local -- drizzle-kit studio",
"seed": "tsx --env-file=.env.local scripts/seed.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:content": "tsx --env-file=.env.local tests/golden/run-content-eval.ts",
"workers": "tsx --env-file=.env.local jobs/index.ts"
},
"dependencies": {
"@ai-sdk/amazon-bedrock": "^4.0.119",
"@ai-sdk/anthropic": "^1.0.0",
"@ai-sdk/azure": "^3.0.76",
"@ai-sdk/cohere": "^3.0.39",
"@ai-sdk/google": "^3.0.83",
"@ai-sdk/groq": "^3.0.42",
"@ai-sdk/mistral": "^3.0.40",
"@ai-sdk/openai": "^1.0.0",
"@ai-sdk/provider": "^1.0.0",
"@ai-sdk/xai": "^3.0.96",
"@auth/drizzle-adapter": "^1.11.2",
"@upstash/ratelimit": "^2.0.8",
"@upstash/redis": "^1.38.0",
"ai": "^4.3.0",
"bcryptjs": "^3.0.3",
"bullmq": "^5.79.0",
"drizzle-orm": "^0.40.0",
"ioredis": "^5.4.0",
"langfuse": "^3.0.0",
"next": "^15.3.0",
"next-auth": "5.0.0-beta.31",
"nodemailer": "^9.0.1",
"ollama-ai-provider": "^1.2.0",
"pg": "^8.13.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"server-only": "^0.0.1",
"zod": "^3.24.0"
},
"devDependencies": {
"@playwright/test": "^1.49.0",
"@tailwindcss/postcss": "^4.1.0",
"@types/bcryptjs": "^3.0.0",
"@types/node": "^22.0.0",
"@types/nodemailer": "^8.0.1",
"@types/pg": "^8.11.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.4.0",
"dotenv": "^16.4.0",
"dotenv-cli": "^11.0.0",
"drizzle-kit": "^0.30.0",
"eslint": "^9.0.0",
"eslint-config-next": "^15.3.0",
"prettier": "^3.5.0",
"tailwindcss": "^4.1.0",
"tsx": "^4.19.0",
"typescript": "^5.8.0",
"vitest": "^3.1.0"
}
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'list',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
webServer: {
command: 'pnpm dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
+7186
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
allowBuilds:
esbuild: true
msgpackr-extract: true
sharp: true
unrs-resolver: true
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
};
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('prettier').Config} */
export default {
semi: true,
singleQuote: true,
trailingComma: 'all',
printWidth: 100,
tabWidth: 2,
};
+185
View File
@@ -0,0 +1,185 @@
/**
* Seed script: JS Closures corpus + blueprint structure.
*
* Idempotent: exits early if source_chunks for this topic already exist.
* Use --force to re-seed (drops + re-inserts).
*
* Usage:
* pnpm seed # idempotent
* pnpm seed -- --force # drop and re-seed
*/
import 'dotenv/config';
import { like, eq } from 'drizzle-orm';
import { db, pool } from '../src/lib/db/index.js';
import {
sourceChunks,
blueprints,
concepts,
misconceptions,
lessons,
segments,
checkpoints,
} from '../src/lib/db/schema.js';
import { embedTexts } from '../src/lib/generation/embed.js';
import {
JS_CLOSURES_CHUNKS,
JS_CLOSURES_BLUEPRINT,
JS_CLOSURES_CONCEPTS,
JS_CLOSURES_MISCONCEPTIONS,
JS_CLOSURES_SEGMENTS,
} from '../src/lib/db/seed/js-closures.js';
const FORCE = process.argv.includes('--force');
async function seed() {
console.log('Seeding JS Closures corpus...');
// Idempotency check
if (!FORCE) {
const existing = await db
.select({ id: sourceChunks.id })
.from(sourceChunks)
.where(like(sourceChunks.docRef, 'js-closures/%'))
.limit(1);
if (existing.length > 0) {
console.log('Already seeded. Run with --force to re-seed.');
return;
}
} else {
console.log('--force: removing existing js-closures data...');
await db.delete(sourceChunks).where(like(sourceChunks.docRef, 'js-closures/%'));
const existingBlueprint = await db
.select({ id: blueprints.id })
.from(blueprints)
.where(eq(blueprints.intentKey, JS_CLOSURES_BLUEPRINT.intentKey))
.limit(1);
if (existingBlueprint.length > 0) {
const bpId = existingBlueprint[0].id;
// Delete bottom-up to respect FK constraints:
// checkpoints → segments → lessons, misconceptions → concepts → blueprint
const lessonRows = await db.select({ id: lessons.id }).from(lessons).where(eq(lessons.blueprintId, bpId));
for (const lesson of lessonRows) {
const segRows = await db.select({ id: segments.id }).from(segments).where(eq(segments.lessonId, lesson.id));
for (const seg of segRows) {
await db.delete(checkpoints).where(eq(checkpoints.segmentId, seg.id));
}
await db.delete(segments).where(eq(segments.lessonId, lesson.id));
}
await db.delete(lessons).where(eq(lessons.blueprintId, bpId));
const conceptRows = await db.select({ id: concepts.id }).from(concepts).where(eq(concepts.blueprintId, bpId));
for (const concept of conceptRows) {
await db.delete(misconceptions).where(eq(misconceptions.conceptId, concept.id));
}
await db.delete(concepts).where(eq(concepts.blueprintId, bpId));
await db.delete(blueprints).where(eq(blueprints.id, bpId));
}
}
// 1. Embed all chunks + blueprint intent text in one batch
// Lowercase matches normalizeIntent's query normalization (input.toLowerCase())
const intentText = JS_CLOSURES_BLUEPRINT.title.toLowerCase(); // "javascript closures"
const allTexts = [intentText, ...JS_CLOSURES_CHUNKS.map((c) => c.text)];
console.log(`Embedding ${allTexts.length} texts (1 blueprint + ${JS_CLOSURES_CHUNKS.length} chunks)...`);
const [blueprintEmbedding, ...chunkEmbeddings] = await embedTexts(allTexts);
console.log('Embeddings done.');
// 2. Insert source_chunks
const insertedChunks = await db
.insert(sourceChunks)
.values(
JS_CLOSURES_CHUNKS.map((chunk, i) => ({
docRef: chunk.docRef,
text: chunk.text,
embedding: chunkEmbeddings[i],
})),
)
.returning({ id: sourceChunks.id, docRef: sourceChunks.docRef });
console.log(`Inserted ${insertedChunks.length} source chunks.`);
// 3. Insert blueprint (with embedding so normalizeIntent similarity search finds it)
const [blueprint] = await db
.insert(blueprints)
.values({ ...JS_CLOSURES_BLUEPRINT, embedding: blueprintEmbedding, status: 'published' })
.returning({ id: blueprints.id });
console.log(`Inserted blueprint: ${blueprint.id}`);
// 4. Insert concepts
const insertedConcepts = await db
.insert(concepts)
.values(JS_CLOSURES_CONCEPTS.map((c) => ({ ...c, blueprintId: blueprint.id })))
.returning({ id: concepts.id, name: concepts.name });
console.log(`Inserted ${insertedConcepts.length} concepts.`);
// 5. Insert misconceptions (keyed by concept name)
const conceptByName = Object.fromEntries(insertedConcepts.map((c) => [c.name, c.id]));
const misconceptionRows = JS_CLOSURES_MISCONCEPTIONS.flatMap((m) => {
const conceptId = conceptByName[m.conceptName];
if (!conceptId) {
console.warn(`No concept found for misconception "${m.conceptName}" — skipping.`);
return [];
}
return [{ conceptId, tag: m.tag, signature: m.signature, verifyStatus: 'pass' as const }];
});
if (misconceptionRows.length > 0) {
await db.insert(misconceptions).values(misconceptionRows);
console.log(`Inserted ${misconceptionRows.length} misconceptions.`);
}
// 6. Insert pre-built lesson (so app works without running generation workers)
const chunkIdByDocRef = Object.fromEntries(insertedChunks.map((c) => [c.docRef, c.id]));
const [lesson] = await db
.insert(lessons)
.values({ blueprintId: blueprint.id, ord: 0, estMinutes: 10 })
.returning({ id: lessons.id });
console.log(`Inserted lesson: ${lesson.id}`);
for (const seg of JS_CLOSURES_SEGMENTS) {
const sourceChunkIds = seg.sourceDocRefs
.map((ref) => chunkIdByDocRef[ref])
.filter((id): id is string => !!id);
const [insertedSegment] = await db
.insert(segments)
.values({
lessonId: lesson.id,
ord: seg.ord,
bodyJson: { text: seg.body },
sourceChunkIds,
verifyStatus: 'pass',
})
.returning({ id: segments.id });
await db.insert(checkpoints).values({
segmentId: insertedSegment.id,
prompt: seg.checkpoint.prompt,
kind: seg.checkpoint.kind,
referenceAnswer: seg.checkpoint.referenceAnswer,
rubricJson: seg.checkpoint.rubric,
verifyStatus: 'pass',
});
console.log(` Segment ${seg.ord}: ${seg.sourceDocRefs.join(', ')}`);
}
console.log('Seed complete.');
}
seed()
.catch((err) => {
console.error('Seed failed:', err);
process.exit(1);
})
.finally(() => pool.end());
@@ -0,0 +1,70 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
type Blueprint = { id: string; intentKey: string; title: string; status: 'draft' | 'verifying' | 'published' | 'flagged'; createdAt: Date };
type Status = Blueprint['status'];
const STATUS_OPTIONS: Status[] = ['draft', 'verifying', 'published', 'flagged'];
export default function AdminBlueprintsClient({ blueprints }: { blueprints: Blueprint[] }) {
const router = useRouter();
const [busy, setBusy] = useState<string | null>(null);
const setStatus = async (id: string, status: Status) => {
setBusy(id);
await fetch(`/api/admin/blueprints/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
});
setBusy(null);
router.refresh();
};
return (
<div>
<h1 style={s.heading}>Blueprints</h1>
<div style={{ overflowX: 'auto' }}>
<table style={s.table}>
<thead>
<tr>
{['Title', 'Intent key', 'Status', 'Created', 'Set status'].map((h) => (
<th key={h} style={s.th}>{h}</th>
))}
</tr>
</thead>
<tbody>
{blueprints.map((bp) => (
<tr key={bp.id}>
<td style={s.td}>{bp.title}</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}>{new Date(bp.createdAt).toLocaleDateString()}</td>
<td style={s.td}>
<select
value={bp.status}
disabled={busy === bp.id}
onChange={(e) => setStatus(bp.id, e.target.value as Status)}
style={s.select}
>
{STATUS_OPTIONS.map((o) => <option key={o} value={o}>{o}</option>)}
</select>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
const s = {
heading: { fontFamily: 'var(--font-display)', fontSize: '1.5rem', fontWeight: 500, color: 'var(--color-ink)', marginBottom: 'var(--space-6)' },
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)' },
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' },
} as const;
+16
View File
@@ -0,0 +1,16 @@
import { requireAdmin } from '@/lib/auth-helpers';
import { db } from '@/lib/db';
import { blueprints } from '@/lib/db/schema';
import { asc } from 'drizzle-orm';
import AdminBlueprintsClient from './blueprints-client';
export default async function AdminBlueprintsPage() {
await requireAdmin();
const rows = await db
.select({ id: blueprints.id, intentKey: blueprints.intentKey, title: blueprints.title, status: blueprints.status, createdAt: blueprints.createdAt })
.from(blueprints)
.orderBy(asc(blueprints.createdAt));
return <AdminBlueprintsClient blueprints={rows} />;
}
+61
View File
@@ -0,0 +1,61 @@
import { requireAdmin } from '@/lib/auth-helpers';
import Link from 'next/link';
import type { ReactNode } from 'react';
export default async function AdminLayout({ children }: { children: ReactNode }) {
await requireAdmin();
return (
<div style={s.shell}>
<nav style={s.sidebar}>
<p style={s.brand}>Admin</p>
<ul style={s.nav}>
{[
{ href: '/admin', label: 'Overview' },
{ href: '/admin/users', label: 'Users' },
{ href: '/admin/reports', label: 'Reports' },
{ href: '/admin/blueprints', label: 'Blueprints' },
{ href: '/admin/settings', label: 'Settings' },
].map(({ href, label }) => (
<li key={href}>
<Link href={href} style={s.navLink}>{label}</Link>
</li>
))}
</ul>
</nav>
<main style={s.content}>{children}</main>
</div>
);
}
const s = {
shell: { display: 'flex', minHeight: '100svh' },
sidebar: {
width: '14rem',
flexShrink: 0,
borderRight: '1px solid var(--color-border)',
padding: 'var(--space-8) var(--space-6)',
display: 'flex',
flexDirection: 'column' as const,
gap: 'var(--space-6)',
},
brand: {
fontFamily: 'var(--font-display)',
fontWeight: 600,
fontSize: '0.875rem',
color: 'var(--color-ink-faint)',
textTransform: 'uppercase' as const,
letterSpacing: '0.1em',
},
nav: { listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column' as const, gap: 'var(--space-1)' },
navLink: {
display: 'block',
padding: 'var(--space-2) var(--space-3)',
borderRadius: '4px',
fontFamily: 'var(--font-display)',
fontSize: '0.9375rem',
color: 'var(--color-ink)',
textDecoration: 'none',
},
content: { flex: 1, padding: 'var(--space-10) var(--space-10)' },
} as const;
+30
View File
@@ -0,0 +1,30 @@
import { db } from '@/lib/db';
import { users, sessions } from '@/lib/db/schema';
import { count, gte } from 'drizzle-orm';
export default async function AdminOverviewPage() {
const [{ total }] = await db.select({ total: count() }).from(users);
const dayAgo = new Date(Date.now() - 86_400_000);
const [{ active }] = await db.select({ active: count() }).from(sessions).where(gte(sessions.expires, dayAgo));
return (
<div>
<h1 style={{ fontFamily: 'var(--font-display)', fontSize: '1.5rem', fontWeight: 500, color: 'var(--color-ink)', marginBottom: 'var(--space-8)' }}>
Overview
</h1>
<div style={{ display: 'flex', gap: 'var(--space-6)' }}>
<Stat label="Total users" value={total} />
<Stat label="Active today" value={active} />
</div>
</div>
);
}
function Stat({ label, value }: { label: string; value: number }) {
return (
<div style={{ padding: 'var(--space-6)', border: '1px solid var(--color-border)', borderRadius: '6px', minWidth: '10rem' }}>
<p style={{ fontFamily: 'var(--font-display)', fontSize: '2rem', fontWeight: 600, color: 'var(--color-ink)', margin: 0 }}>{value}</p>
<p style={{ fontSize: '0.875rem', color: 'var(--color-ink-muted)', margin: 0 }}>{label}</p>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { requireAdmin } from '@/lib/auth-helpers';
import { db } from '@/lib/db';
import { contentReports } from '@/lib/db/schema';
import { asc } from 'drizzle-orm';
import AdminReportsClient from './reports-client';
export default async function AdminReportsPage() {
await requireAdmin();
const rows = await db
.select()
.from(contentReports)
.orderBy(asc(contentReports.createdAt));
return <AdminReportsClient reports={rows} />;
}
+60
View File
@@ -0,0 +1,60 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
type Report = { id: string; checkpointId: string; gradeId: string | null; reason: string; notes: string | null; createdAt: Date };
export default function AdminReportsClient({ reports }: { reports: Report[] }) {
const router = useRouter();
const [busy, setBusy] = useState<string | null>(null);
const act = async (id: string, action: 'dismiss' | 're-verify') => {
setBusy(id);
await fetch(`/api/admin/reports/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action }),
});
setBusy(null);
router.refresh();
};
return (
<div>
<h1 style={s.heading}>Content reports</h1>
{reports.length === 0 ? (
<p style={{ color: 'var(--color-ink-muted)' }}>No pending reports.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
{reports.map((r) => (
<div key={r.id} style={s.card}>
<div style={s.cardMeta}>
<span style={s.tag}>{r.reason}</span>
<span style={s.date}>{new Date(r.createdAt).toLocaleDateString()}</span>
</div>
{r.notes && <p style={s.notes}>{r.notes}</p>}
<p style={{ fontSize: '0.8125rem', color: 'var(--color-ink-faint)', fontFamily: 'var(--font-display)' }}>
checkpoint {r.checkpointId.slice(0, 8)}
</p>
<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, 're-verify')} style={s.btn}>Flag for re-verify</button>
</div>
</div>
))}
</div>
)}
</div>
);
}
const s = {
heading: { fontFamily: 'var(--font-display)', fontSize: '1.5rem', fontWeight: 500, color: 'var(--color-ink)', marginBottom: 'var(--space-6)' },
card: { padding: 'var(--space-5)', border: '1px solid var(--color-border)', borderRadius: '4px', display: 'flex', flexDirection: 'column' as const, gap: 'var(--space-2)' },
cardMeta: { display: 'flex', gap: 'var(--space-3)', alignItems: 'center' },
tag: { fontFamily: 'var(--font-display)', fontSize: '0.75rem', fontWeight: 600, padding: '2px 8px', background: 'var(--color-accent-subtle)', color: 'var(--color-accent)', borderRadius: '3px' },
date: { fontSize: '0.8125rem', color: 'var(--color-ink-faint)', fontFamily: 'var(--font-display)' },
notes: { fontSize: '0.9375rem', color: 'var(--color-ink-muted)' },
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;
+7
View File
@@ -0,0 +1,7 @@
import { getAllSettings } from '@/lib/site-config';
import AdminSettingsClient from './settings-client';
export default async function AdminSettingsPage() {
const settings = await getAllSettings();
return <AdminSettingsClient initial={settings} />;
}
@@ -0,0 +1,90 @@
'use client';
import { useState } from 'react';
interface Settings {
require_auth: boolean;
signups_enabled: boolean;
}
export default function AdminSettingsClient({ initial }: { initial: Settings }) {
const [settings, setSettings] = useState<Settings>(initial);
const [saving, setSaving] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const toggle = async (key: keyof Settings) => {
setSaving(key);
setMessage(null);
const next = { [key]: !settings[key] };
const res = await fetch('/api/admin/settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(next),
});
setSaving(null);
if (res.ok) {
const data = (await res.json()) as Settings;
setSettings(data);
setMessage('Saved.');
setTimeout(() => setMessage(null), 2000);
} else {
setMessage('Failed to save.');
}
};
return (
<div>
<h1 style={s.heading}>Site settings</h1>
{message && <p role="status" style={s.toast}>{message}</p>}
<section style={s.section}>
<h2 style={s.sectionHeading}>Access control</h2>
<div style={s.row}>
<div>
<p style={s.label}>Require account to access site</p>
<p style={s.desc}>Anonymous access is blocked. All visitors must sign in.</p>
</div>
<button
type="button"
role="switch"
aria-checked={settings.require_auth}
onClick={() => toggle('require_auth')}
disabled={saving === 'require_auth'}
style={settings.require_auth ? { ...s.toggle, ...s.toggleOn } : s.toggle}
>
{settings.require_auth ? 'On' : 'Off'}
</button>
</div>
<div style={s.row}>
<div>
<p style={s.label}>Allow new registrations</p>
<p style={s.desc}>When off, the registration form returns 403. Existing accounts still work.</p>
</div>
<button
type="button"
role="switch"
aria-checked={settings.signups_enabled}
onClick={() => toggle('signups_enabled')}
disabled={saving === 'signups_enabled'}
style={settings.signups_enabled ? { ...s.toggle, ...s.toggleOn } : s.toggle}
>
{settings.signups_enabled ? 'On' : 'Off'}
</button>
</div>
</section>
</div>
);
}
const s = {
heading: { fontFamily: 'var(--font-display)', fontSize: '1.25rem', fontWeight: 500, color: 'var(--color-ink)', marginBottom: 'var(--space-8)' },
toast: { padding: 'var(--space-2) var(--space-4)', background: 'var(--color-surface)', border: '1px solid var(--color-border)', borderRadius: '4px', fontSize: '0.875rem', color: 'var(--color-ink-muted)', marginBottom: 'var(--space-4)' },
section: { marginBottom: 'var(--space-10)' },
sectionHeading: { fontFamily: 'var(--font-display)', fontSize: '0.875rem', fontWeight: 600, color: 'var(--color-ink-faint)', textTransform: 'uppercase' as const, letterSpacing: '0.08em', marginBottom: 'var(--space-4)' },
row: { display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 'var(--space-6)', padding: 'var(--space-4) 0', borderBottom: '1px solid var(--color-border)' },
label: { fontFamily: 'var(--font-display)', fontSize: '0.9375rem', fontWeight: 500, color: 'var(--color-ink)', margin: '0 0 var(--space-1)' },
desc: { fontFamily: 'var(--font-body)', fontSize: '0.875rem', color: 'var(--color-ink-muted)', margin: 0 },
toggle: { flexShrink: 0, padding: 'var(--space-2) var(--space-4)', border: '1px solid var(--color-border)', borderRadius: '4px', fontFamily: 'var(--font-display)', fontSize: '0.875rem', fontWeight: 500, cursor: 'pointer', background: 'var(--color-surface)', color: 'var(--color-ink-muted)' },
toggleOn: { background: 'var(--color-accent)', color: '#fff', border: '1px solid var(--color-accent)' },
} as const;
+16
View File
@@ -0,0 +1,16 @@
import { requireAdmin } from '@/lib/auth-helpers';
import { db } from '@/lib/db';
import { users } from '@/lib/db/schema';
import { asc } from 'drizzle-orm';
import AdminUsersClient from './users-client';
export default async function AdminUsersPage() {
await requireAdmin();
const rows = await db
.select({ id: users.id, name: users.name, email: users.email, role: users.role, createdAt: users.createdAt })
.from(users)
.orderBy(asc(users.createdAt));
return <AdminUsersClient users={rows} />;
}
+86
View File
@@ -0,0 +1,86 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
type UserRow = { id: string; name: string | null; email: string | null; role: 'learner' | 'admin' | 'suspended'; createdAt: Date };
export default function AdminUsersClient({ users }: { users: UserRow[] }) {
const router = useRouter();
const [busy, setBusy] = useState<string | null>(null);
const action = async (id: string, body: object) => {
setBusy(id);
await fetch(`/api/admin/users/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
setBusy(null);
router.refresh();
};
const del = async (id: string) => {
if (!confirm('Delete this user permanently?')) return;
setBusy(id);
await fetch(`/api/admin/users/${id}`, { method: 'DELETE' });
setBusy(null);
router.refresh();
};
return (
<div>
<h1 style={s.heading}>Users</h1>
<div style={{ overflowX: 'auto' }}>
<table style={s.table}>
<thead>
<tr>
{['Email', 'Name', 'Role', 'Joined', 'Actions'].map((h) => (
<th key={h} style={s.th}>{h}</th>
))}
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td style={s.td}>{u.email ?? '—'}</td>
<td style={s.td}>{u.name ?? '—'}</td>
<td style={s.td}>{u.role}</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 }}>
{u.role !== 'admin' && (
<button disabled={busy === u.id} onClick={() => action(u.id, { role: 'admin' })} style={s.btn}>
Make admin
</button>
)}
{u.role !== 'suspended' && (
<button disabled={busy === u.id} onClick={() => action(u.id, { role: 'suspended' })} style={s.btn}>
Suspend
</button>
)}
{u.role === 'suspended' && (
<button disabled={busy === u.id} onClick={() => action(u.id, { role: 'learner' })} style={s.btn}>
Unsuspend
</button>
)}
<button disabled={busy === u.id} onClick={() => del(u.id)} style={s.dangerBtn}>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
const s = {
heading: { fontFamily: 'var(--font-display)', fontSize: '1.5rem', fontWeight: 500, color: 'var(--color-ink)', marginBottom: 'var(--space-6)' },
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)' },
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;
@@ -0,0 +1,157 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
vi.mock('@/lib/db', () => ({
db: {
update: vi.fn(),
},
}));
vi.mock('@/lib/db/schema', () => ({
blueprints: { id: 'id' },
}));
vi.mock('drizzle-orm', () => ({
eq: vi.fn().mockReturnValue('eq-condition'),
}));
import { PATCH } from '../route';
import { db } from '@/lib/db';
import { requireAdmin } from '@/lib/auth-helpers';
function makeRequest(body: unknown, contentType = 'application/json'): NextRequest {
return new NextRequest('http://localhost/api/admin/blueprints/test-id', {
method: 'PATCH',
headers: { 'Content-Type': contentType },
body: JSON.stringify(body),
});
}
function makeInvalidJsonRequest(): NextRequest {
return new NextRequest('http://localhost/api/admin/blueprints/test-id', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: '{invalid-json',
});
}
const mockWhere = vi.fn().mockResolvedValue([]);
const mockSet = vi.fn().mockReturnValue({ where: mockWhere });
const mockUpdate = vi.fn().mockReturnValue({ set: mockSet });
describe('PATCH /api/admin/blueprints/[id]', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(db).update = mockUpdate;
mockUpdate.mockReturnValue({ set: mockSet });
mockSet.mockReturnValue({ where: mockWhere });
mockWhere.mockResolvedValue([]);
});
it('returns 200 with ok:true on valid status update', async () => {
const res = await PATCH(makeRequest({ status: 'published' }), {
params: Promise.resolve({ id: 'blueprint-uuid' }),
});
expect(res.status).toBe(200);
const json = await res.json();
expect(json).toEqual({ ok: true });
expect(mockUpdate).toHaveBeenCalled();
expect(mockSet).toHaveBeenCalledWith({ status: 'published' });
});
it('returns 200 with each valid status value', async () => {
const statuses = ['draft', 'verifying', 'published', 'flagged'] as const;
for (const status of statuses) {
vi.clearAllMocks();
vi.mocked(db).update = mockUpdate;
mockUpdate.mockReturnValue({ set: mockSet });
mockSet.mockReturnValue({ where: mockWhere });
mockWhere.mockResolvedValue([]);
const res = await PATCH(makeRequest({ status }), {
params: Promise.resolve({ id: 'blueprint-uuid' }),
});
expect(res.status).toBe(200);
const json = await res.json();
expect(json).toEqual({ ok: true });
}
});
it('returns 400 on invalid status value', async () => {
const res = await PATCH(makeRequest({ status: 'invalid-status' }), {
params: Promise.resolve({ id: 'blueprint-uuid' }),
});
expect(res.status).toBe(400);
const json = await res.json();
expect(json).toHaveProperty('error');
});
it('returns 400 on invalid JSON body', async () => {
const res = await PATCH(makeInvalidJsonRequest(), {
params: Promise.resolve({ id: 'blueprint-uuid' }),
});
expect(res.status).toBe(400);
const json = await res.json();
expect(json).toEqual({ error: 'Invalid JSON' });
});
it('returns 400 on extra unknown fields that fail schema', async () => {
// PatchSchema uses z.object with no strict — unknown fields are stripped, so
// an entirely missing-status (empty object) is still valid (status is optional).
// Test that a wrong type for status triggers validation failure.
const res = await PATCH(makeRequest({ status: 123 }), {
params: Promise.resolve({ id: 'blueprint-uuid' }),
});
expect(res.status).toBe(400);
const json = await res.json();
expect(json).toHaveProperty('error');
});
it('calls requireAdmin for authorization', async () => {
await PATCH(makeRequest({ status: 'draft' }), {
params: Promise.resolve({ id: 'blueprint-uuid' }),
});
expect(vi.mocked(requireAdmin)).toHaveBeenCalled();
});
it('returns 403 when requireAdmin throws', async () => {
vi.mocked(requireAdmin).mockRejectedValueOnce(new Error('Forbidden'));
await expect(
PATCH(makeRequest({ status: 'draft' }), {
params: Promise.resolve({ id: 'blueprint-uuid' }),
})
).rejects.toThrow('Forbidden');
});
it('passes the correct id from params to the db update', async () => {
const targetId = 'specific-blueprint-id-123';
await PATCH(makeRequest({ status: 'flagged' }), {
params: Promise.resolve({ id: targetId }),
});
expect(mockUpdate).toHaveBeenCalled();
expect(mockSet).toHaveBeenCalledWith({ status: 'flagged' });
// eq() should have been called with the id
const { eq } = await import('drizzle-orm');
expect(vi.mocked(eq)).toHaveBeenCalledWith(expect.anything(), targetId);
});
it('returns 200 with empty body (all fields optional)', async () => {
const res = await PATCH(makeRequest({}), {
params: Promise.resolve({ id: 'blueprint-uuid' }),
});
expect(res.status).toBe(200);
const json = await res.json();
expect(json).toEqual({ ok: true });
});
});
@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { blueprints } from '@/lib/db/schema';
import { requireAdmin } from '@/lib/auth-helpers';
const PatchSchema = z.object({
status: z.enum(['draft', 'verifying', 'published', 'flagged']).optional(),
});
export async function PATCH(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 = PatchSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, { status: 400 });
await db.update(blueprints).set(parsed.data).where(eq(blueprints.id, id));
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,58 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockSelect } = vi.hoisted(() => ({ mockSelect: vi.fn() }));
vi.mock('@/lib/db', () => ({ db: { select: mockSelect } }));
import { GET } from '../route';
import { requireAdmin } from '@/lib/auth-helpers';
const fixtureBlueprintsRows = [
{ id: 'bp-1', intentKey: 'typescript-basics', title: 'TypeScript Basics', status: 'published', createdAt: new Date('2024-01-01') },
{ id: 'bp-2', intentKey: 'react-hooks', title: 'React Hooks', status: 'draft', createdAt: new Date('2024-01-02') },
];
function mockChain(result: unknown) {
return { from: vi.fn().mockReturnValue({ orderBy: vi.fn().mockResolvedValue(result) }) };
}
describe('GET /api/admin/blueprints', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns blueprints list on happy path', async () => {
mockSelect.mockReturnValue(mockChain(fixtureBlueprintsRows).from());
mockSelect.mockReturnValue(mockChain(fixtureBlueprintsRows));
const res = await GET();
const body = await res.json();
expect(res.status).toBe(200);
expect(body.blueprints).toHaveLength(2);
});
it('returns empty array when no blueprints', async () => {
mockSelect.mockReturnValue(mockChain([]));
const res = await GET();
const body = await res.json();
expect(res.status).toBe(200);
expect(body.blueprints).toEqual([]);
});
it('calls requireAdmin', async () => {
mockSelect.mockReturnValue(mockChain([]));
await GET();
expect(requireAdmin).toHaveBeenCalledOnce();
});
it('propagates requireAdmin rejection', async () => {
vi.mocked(requireAdmin).mockRejectedValueOnce(new Error('forbidden'));
await expect(GET()).rejects.toThrow('forbidden');
});
it('propagates DB errors', async () => {
mockSelect.mockReturnValue({
from: vi.fn().mockReturnValue({ orderBy: vi.fn().mockRejectedValue(new Error('DB error')) }),
});
await expect(GET()).rejects.toThrow('DB error');
});
});
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse } from 'next/server';
import { asc } from 'drizzle-orm';
import { db } from '@/lib/db';
import { blueprints } from '@/lib/db/schema';
import { requireAdmin } from '@/lib/auth-helpers';
export async function GET(): Promise<NextResponse> {
await requireAdmin();
const rows = await db
.select({ id: blueprints.id, intentKey: blueprints.intentKey, title: blueprints.title, status: blueprints.status, createdAt: blueprints.createdAt })
.from(blueprints)
.orderBy(asc(blueprints.createdAt));
return NextResponse.json({ blueprints: rows });
}
@@ -0,0 +1,161 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
vi.mock('@/lib/db', () => ({
db: {
select: vi.fn(),
insert: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
}));
import { PATCH } from '../route';
import { db } from '@/lib/db';
import { requireAdmin } from '@/lib/auth-helpers';
function makeRequest(body: unknown): NextRequest {
return new NextRequest('http://localhost/api/admin/reports/test-id', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
function makeInvalidRequest(): NextRequest {
return new NextRequest('http://localhost/api/admin/reports/test-id', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: 'not-json{{{',
});
}
describe('PATCH /api/admin/reports/[id]', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns 400 on invalid JSON', async () => {
const res = await PATCH(makeInvalidRequest(), { params: Promise.resolve({ id: 'test-id' }) });
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toBe('Invalid JSON');
});
it('returns 400 on invalid action', async () => {
const res = await PATCH(makeRequest({ action: 'invalid-action' }), { params: Promise.resolve({ id: 'test-id' }) });
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toBeDefined();
});
it('returns 400 when action is missing', async () => {
const res = await PATCH(makeRequest({}), { params: Promise.resolve({ id: 'test-id' }) });
expect(res.status).toBe(400);
});
it('calls requireAdmin for auth guard', async () => {
vi.mocked(db).delete = vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
});
await PATCH(makeRequest({ action: 'dismiss' }), { params: Promise.resolve({ id: 'test-id' }) });
expect(requireAdmin).toHaveBeenCalled();
});
it('returns 401/403 when requireAdmin rejects', async () => {
vi.mocked(requireAdmin).mockRejectedValueOnce(new Error('redirect'));
await expect(
PATCH(makeRequest({ action: 'dismiss' }), { params: Promise.resolve({ id: 'test-id' }) })
).rejects.toThrow('redirect');
});
describe('dismiss action', () => {
it('deletes the report and returns {ok: true}', async () => {
const mockWhere = vi.fn().mockResolvedValue([]);
vi.mocked(db).delete = vi.fn().mockReturnValue({ where: mockWhere });
const res = await PATCH(makeRequest({ action: 'dismiss' }), { params: Promise.resolve({ id: 'report-uuid' }) });
expect(res.status).toBe(200);
const data = await res.json();
expect(data).toEqual({ ok: true });
expect(db.delete).toHaveBeenCalled();
expect(mockWhere).toHaveBeenCalled();
});
it('does not call select or update for dismiss', async () => {
const mockWhere = vi.fn().mockResolvedValue([]);
vi.mocked(db).delete = vi.fn().mockReturnValue({ where: mockWhere });
await PATCH(makeRequest({ action: 'dismiss' }), { params: Promise.resolve({ id: 'report-uuid' }) });
expect(db.select).not.toHaveBeenCalled();
expect(db.update).not.toHaveBeenCalled();
});
});
describe('re-verify action', () => {
it('selects report, updates checkpoint to pending, returns {ok: true}', async () => {
const mockSelectWhere = vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ checkpointId: 'checkpoint-uuid' }]),
});
vi.mocked(db).select = vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: mockSelectWhere,
}),
});
const mockUpdateWhere = vi.fn().mockResolvedValue([]);
vi.mocked(db).update = vi.fn().mockReturnValue({
set: vi.fn().mockReturnValue({ where: mockUpdateWhere }),
});
const res = await PATCH(makeRequest({ action: 're-verify' }), { params: Promise.resolve({ id: 'report-uuid' }) });
expect(res.status).toBe(200);
const data = await res.json();
expect(data).toEqual({ ok: true });
expect(db.select).toHaveBeenCalled();
expect(db.update).toHaveBeenCalled();
expect(mockUpdateWhere).toHaveBeenCalled();
});
it('skips update when report is not found', async () => {
vi.mocked(db).select = vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]),
}),
}),
});
const res = await PATCH(makeRequest({ action: 're-verify' }), { params: Promise.resolve({ id: 'nonexistent-id' }) });
expect(res.status).toBe(200);
const data = await res.json();
expect(data).toEqual({ ok: true });
expect(db.update).not.toHaveBeenCalled();
});
it('does not call delete for re-verify', async () => {
vi.mocked(db).select = vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ checkpointId: 'checkpoint-uuid' }]),
}),
}),
});
vi.mocked(db).update = vi.fn().mockReturnValue({
set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
});
await PATCH(makeRequest({ action: 're-verify' }), { params: Promise.resolve({ id: 'report-uuid' }) });
expect(db.delete).not.toHaveBeenCalled();
});
});
});
+32
View File
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { contentReports, checkpoints } from '@/lib/db/schema';
import { requireAdmin } from '@/lib/auth-helpers';
const PatchSchema = z.object({
action: z.enum(['dismiss', 're-verify']),
});
export async function PATCH(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 = PatchSchema.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 db.delete(contentReports).where(eq(contentReports.id, id));
} else {
const [report] = await db.select({ checkpointId: contentReports.checkpointId }).from(contentReports).where(eq(contentReports.id, id)).limit(1);
if (report) {
await db.update(checkpoints).set({ verifyStatus: 'pending' }).where(eq(checkpoints.id, report.checkpointId));
}
}
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,57 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockSelect } = vi.hoisted(() => ({ mockSelect: vi.fn() }));
vi.mock('@/lib/db', () => ({ db: { select: mockSelect } }));
import { GET } from '../route';
import { requireAdmin } from '@/lib/auth-helpers';
const sampleReports = [
{ id: 'r-1', checkpointId: 'cp-1', gradeId: null, reason: 'grade_wrong', notes: null, createdAt: new Date('2024-01-01') },
{ id: 'r-2', checkpointId: 'cp-2', gradeId: 'g-2', reason: 'content_error', notes: 'wrong info', createdAt: new Date('2024-01-02') },
];
function mockChain(result: unknown) {
return { from: vi.fn().mockReturnValue({ orderBy: vi.fn().mockResolvedValue(result) }) };
}
describe('GET /api/admin/reports', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns reports list on happy path', async () => {
mockSelect.mockReturnValue(mockChain(sampleReports));
const res = await GET();
const body = await res.json();
expect(res.status).toBe(200);
expect(body.reports).toHaveLength(2);
});
it('returns empty array when no reports', async () => {
mockSelect.mockReturnValue(mockChain([]));
const res = await GET();
const body = await res.json();
expect(res.status).toBe(200);
expect(body.reports).toEqual([]);
});
it('calls requireAdmin', async () => {
mockSelect.mockReturnValue(mockChain([]));
await GET();
expect(requireAdmin).toHaveBeenCalledOnce();
});
it('propagates requireAdmin rejection', async () => {
vi.mocked(requireAdmin).mockRejectedValueOnce(new Error('forbidden'));
await expect(GET()).rejects.toThrow('forbidden');
});
it('propagates DB errors', async () => {
mockSelect.mockReturnValue({
from: vi.fn().mockReturnValue({ orderBy: vi.fn().mockRejectedValue(new Error('DB error')) }),
});
await expect(GET()).rejects.toThrow('DB error');
});
});
+11
View File
@@ -0,0 +1,11 @@
import { NextResponse } from 'next/server';
import { asc } from 'drizzle-orm';
import { db } from '@/lib/db';
import { contentReports } from '@/lib/db/schema';
import { requireAdmin } from '@/lib/auth-helpers';
export async function GET(): Promise<NextResponse> {
await requireAdmin();
const rows = await db.select().from(contentReports).orderBy(asc(contentReports.createdAt));
return NextResponse.json({ reports: rows });
}
+36
View File
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { requireAdmin } from '@/lib/auth-helpers';
import { getAllSettings, setAuthRequired, setSignupsEnabled } from '@/lib/site-config';
const PatchSchema = z.object({
require_auth: z.boolean().optional(),
signups_enabled: z.boolean().optional(),
});
export async function GET(): Promise<NextResponse> {
await requireAdmin();
const settings = await getAllSettings();
return NextResponse.json(settings);
}
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 = PatchSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, { status: 400 });
}
const { require_auth, signups_enabled } = parsed.data;
await Promise.all([
require_auth !== undefined ? setAuthRequired(require_auth) : Promise.resolve(),
signups_enabled !== undefined ? setSignupsEnabled(signups_enabled) : Promise.resolve(),
]);
const settings = await getAllSettings();
return NextResponse.json(settings);
}
@@ -0,0 +1,65 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockSelect } = vi.hoisted(() => ({ mockSelect: vi.fn() }));
vi.mock('@/lib/db', () => ({ db: { select: mockSelect } }));
import { GET } from '../route';
import { requireAdmin } from '@/lib/auth-helpers';
describe('GET /api/admin/stats', () => {
beforeEach(() => {
vi.clearAllMocks();
});
function mockUserCount(total: number) {
mockSelect.mockReturnValueOnce({ from: vi.fn().mockResolvedValue([{ total }]) });
}
function mockSessionCount(active: number) {
mockSelect.mockReturnValueOnce({
from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([{ active }]) }),
});
}
it('returns totalUsers and activeToday', async () => {
mockUserCount(42);
mockSessionCount(7);
const res = await GET();
const body = await res.json();
expect(res.status).toBe(200);
expect(body).toEqual({ totalUsers: 42, activeToday: 7 });
});
it('returns zero counts when empty', async () => {
mockUserCount(0);
mockSessionCount(0);
const res = await GET();
const body = await res.json();
expect(body).toEqual({ totalUsers: 0, activeToday: 0 });
});
it('calls requireAdmin', async () => {
mockUserCount(1);
mockSessionCount(0);
await GET();
expect(requireAdmin).toHaveBeenCalledOnce();
});
it('propagates requireAdmin rejection', async () => {
vi.mocked(requireAdmin).mockRejectedValueOnce(new Error('forbidden'));
await expect(GET()).rejects.toThrow('forbidden');
});
it('calls select twice', async () => {
mockUserCount(5);
mockSessionCount(2);
await GET();
expect(mockSelect).toHaveBeenCalledTimes(2);
});
it('propagates DB errors from user count query', async () => {
mockSelect.mockReturnValueOnce({ from: vi.fn().mockRejectedValue(new Error('DB error')) });
await expect(GET()).rejects.toThrow('DB error');
});
});
+19
View File
@@ -0,0 +1,19 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { users, sessions } from '@/lib/db/schema';
import { requireAdmin } from '@/lib/auth-helpers';
import { count, gte } from 'drizzle-orm';
export async function GET(): Promise<NextResponse> {
await requireAdmin();
const [{ total }] = await db.select({ total: count() }).from(users);
const dayAgo = new Date(Date.now() - 86_400_000);
const [{ active }] = await db
.select({ active: count() })
.from(sessions)
.where(gte(sessions.expires, dayAgo));
return NextResponse.json({ totalUsers: total, activeToday: active });
}
@@ -0,0 +1,153 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
vi.mock('@/lib/db', () => ({
db: {
update: vi.fn(),
delete: vi.fn(),
transaction: vi.fn(),
},
}));
import { db } from '@/lib/db';
import { PATCH, DELETE } from '../route';
import { requireAdmin } from '@/lib/auth-helpers';
function makeRequest(body?: unknown, method = 'PATCH'): NextRequest {
return new NextRequest('http://localhost/api/admin/users/some-uuid', {
method,
headers: { 'Content-Type': 'application/json' },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
}
const TEST_USER_ID = 'user-uuid-123';
describe('PATCH /api/admin/users/[id]', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(db).update = vi.fn().mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
});
});
it('returns 200 with ok:true on valid role update', async () => {
const res = await PATCH(makeRequest({ role: 'admin' }), {
params: Promise.resolve({ id: TEST_USER_ID }),
});
expect(res.status).toBe(200);
const json = await res.json();
expect(json).toEqual({ ok: true });
});
it('calls db.update with the correct role', async () => {
await PATCH(makeRequest({ role: 'suspended' }), {
params: Promise.resolve({ id: TEST_USER_ID }),
});
expect(vi.mocked(db).update).toHaveBeenCalled();
const setMock = vi.mocked(db).update({} as never).set;
expect(setMock).toHaveBeenCalledWith({ role: 'suspended' });
});
it('returns 400 on invalid role value', async () => {
const res = await PATCH(makeRequest({ role: 'superuser' }), {
params: Promise.resolve({ id: TEST_USER_ID }),
});
expect(res.status).toBe(400);
const json = await res.json();
expect(json).toHaveProperty('error');
});
it('returns 400 on invalid JSON', async () => {
const req = new NextRequest('http://localhost/api/admin/users/some-uuid', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: 'not-json{{{',
});
const res = await PATCH(req, {
params: Promise.resolve({ id: TEST_USER_ID }),
});
expect(res.status).toBe(400);
const json = await res.json();
expect(json.error).toBe('Invalid JSON');
});
it('returns 403 when requireAdmin throws', async () => {
vi.mocked(requireAdmin).mockRejectedValueOnce(new Error('Forbidden'));
await expect(
PATCH(makeRequest({ role: 'learner' }), {
params: Promise.resolve({ id: TEST_USER_ID }),
})
).rejects.toThrow('Forbidden');
});
it('accepts learner as a valid role', async () => {
const res = await PATCH(makeRequest({ role: 'learner' }), {
params: Promise.resolve({ id: TEST_USER_ID }),
});
expect(res.status).toBe(200);
});
it('returns 200 when body has no role (optional field)', async () => {
const res = await PATCH(makeRequest({}), {
params: Promise.resolve({ id: TEST_USER_ID }),
});
expect(res.status).toBe(200);
});
});
describe('DELETE /api/admin/users/[id]', () => {
let txMock: {
delete: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
vi.clearAllMocks();
txMock = {
delete: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
};
vi.mocked(db).transaction = vi.fn().mockImplementation(async (fn) => fn(txMock));
});
it('returns 200 with ok:true on successful delete', async () => {
const res = await DELETE(makeRequest(undefined, 'DELETE'), {
params: Promise.resolve({ id: TEST_USER_ID }),
});
expect(res.status).toBe(200);
const json = await res.json();
expect(json).toEqual({ ok: true });
});
it('calls transaction and deletes responses, mastery, journeyInstances, users', async () => {
await DELETE(makeRequest(undefined, 'DELETE'), {
params: Promise.resolve({ id: TEST_USER_ID }),
});
expect(vi.mocked(db).transaction).toHaveBeenCalledOnce();
expect(txMock.delete).toHaveBeenCalledTimes(4);
});
it('returns 403 when requireAdmin throws', async () => {
vi.mocked(requireAdmin).mockRejectedValueOnce(new Error('Forbidden'));
await expect(
DELETE(makeRequest(undefined, 'DELETE'), {
params: Promise.resolve({ id: TEST_USER_ID }),
})
).rejects.toThrow('Forbidden');
});
it('propagates transaction errors', async () => {
vi.mocked(db).transaction = vi.fn().mockRejectedValue(new Error('DB error'));
await expect(
DELETE(makeRequest(undefined, 'DELETE'), {
params: Promise.resolve({ id: TEST_USER_ID }),
})
).rejects.toThrow('DB error');
});
});
+38
View File
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { users, responses, mastery, journeyInstances } from '@/lib/db/schema';
import { requireAdmin } from '@/lib/auth-helpers';
const PatchSchema = z.object({
role: z.enum(['learner', 'admin', 'suspended']).optional(),
});
export async function PATCH(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 = PatchSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, { status: 400 });
await db.update(users).set(parsed.data).where(eq(users.id, id));
return NextResponse.json({ ok: true });
}
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }): Promise<NextResponse> {
await requireAdmin();
const { id } = await params;
await db.transaction(async (tx) => {
await tx.delete(responses).where(eq(responses.userId, id));
await tx.delete(mastery).where(eq(mastery.userId, id));
await tx.delete(journeyInstances).where(eq(journeyInstances.userId, id));
await tx.delete(users).where(eq(users.id, id));
});
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,86 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
const { mockSelect } = vi.hoisted(() => ({ mockSelect: vi.fn() }));
vi.mock('@/lib/db', () => ({ db: { select: mockSelect } }));
import { GET } from '../route';
import { requireAdmin } from '@/lib/auth-helpers';
const mockUsers = [
{ id: 'u-1', name: 'Alice', email: 'alice@example.com', role: 'learner', createdAt: new Date('2024-01-01') },
{ id: 'u-2', name: 'Bob', email: 'bob@example.com', role: 'admin', createdAt: new Date('2024-01-02') },
];
function makeRequest(url = 'http://localhost/api/admin/users') {
return new NextRequest(url);
}
function mockChain(result: unknown) {
return {
from: vi.fn().mockReturnValue({
orderBy: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue({
offset: vi.fn().mockResolvedValue(result),
}),
}),
}),
};
}
describe('GET /api/admin/users', () => {
beforeEach(() => {
vi.clearAllMocks();
mockSelect.mockReturnValue(mockChain(mockUsers));
});
it('returns paginated users on happy path (page 1)', async () => {
const res = await GET(makeRequest('http://localhost/api/admin/users?page=1'));
const body = await res.json();
expect(res.status).toBe(200);
expect(body.users).toHaveLength(mockUsers.length);
expect(body.users[0].id).toBe('u-1');
expect(body.page).toBe(1);
});
it('defaults to page 1 when page param missing', async () => {
const res = await GET(makeRequest());
const body = await res.json();
expect(body.page).toBe(1);
});
it('returns correct page for page 2', async () => {
const res = await GET(makeRequest('http://localhost/api/admin/users?page=2'));
const body = await res.json();
expect(body.page).toBe(2);
});
it('returns empty array when no users', async () => {
mockSelect.mockReturnValue(mockChain([]));
const res = await GET(makeRequest());
const body = await res.json();
expect(body.users).toEqual([]);
});
it('calls requireAdmin', async () => {
await GET(makeRequest());
expect(requireAdmin).toHaveBeenCalledOnce();
});
it('propagates requireAdmin rejection', async () => {
vi.mocked(requireAdmin).mockRejectedValueOnce(new Error('forbidden'));
await expect(GET(makeRequest())).rejects.toThrow('forbidden');
});
it('propagates DB errors', async () => {
mockSelect.mockReturnValue({
from: vi.fn().mockReturnValue({
orderBy: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue({ offset: vi.fn().mockRejectedValue(new Error('DB error')) }),
}),
}),
});
await expect(GET(makeRequest())).rejects.toThrow('DB error');
});
});
+22
View File
@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { users } from '@/lib/db/schema';
import { requireAdmin } from '@/lib/auth-helpers';
import { asc } from 'drizzle-orm';
export async function GET(req: NextRequest): Promise<NextResponse> {
await requireAdmin();
const page = parseInt(req.nextUrl.searchParams.get('page') ?? '1', 10);
const limit = 50;
const offset = (page - 1) * limit;
const rows = await db
.select({ id: users.id, name: users.name, email: users.email, role: users.role, createdAt: users.createdAt })
.from(users)
.orderBy(asc(users.createdAt))
.limit(limit)
.offset(offset);
return NextResponse.json({ users: rows, page });
}
@@ -0,0 +1,73 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
vi.mock('@/lib/db/queries', () => ({
advanceJourneyPosition: vi.fn(),
}));
import { POST } from '../route';
import { advanceJourneyPosition } from '@/lib/db/queries';
const mockAdvance = vi.mocked(advanceJourneyPosition);
const VALID_BODY = {
userId: '123e4567-e89b-12d3-a456-426614174000',
blueprintId: '223e4567-e89b-12d3-a456-426614174001',
};
function makeRequest(body: unknown) {
return new NextRequest('http://localhost:3000/api/advance', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
describe('POST /api/advance', () => {
beforeEach(() => vi.clearAllMocks());
it('returns 400 on malformed JSON', async () => {
const req = new NextRequest('http://localhost:3000/api/advance', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{not json',
});
const res = await POST(req);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe('Invalid JSON');
});
it('returns 400 when userId missing', async () => {
const res = await POST(makeRequest({ blueprintId: VALID_BODY.blueprintId }));
expect(res.status).toBe(400);
});
it('returns 400 when blueprintId missing', async () => {
const res = await POST(makeRequest({ userId: VALID_BODY.userId }));
expect(res.status).toBe(400);
});
it('returns 400 when userId is not a UUID', async () => {
const res = await POST(makeRequest({ ...VALID_BODY, userId: 'not-a-uuid' }));
expect(res.status).toBe(400);
});
it('returns 400 when blueprintId is not a UUID', async () => {
const res = await POST(makeRequest({ ...VALID_BODY, blueprintId: 'not-a-uuid' }));
expect(res.status).toBe(400);
});
it('returns 200 with position on success', async () => {
mockAdvance.mockResolvedValue(2);
const res = await POST(makeRequest(VALID_BODY));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.position).toBe(2);
});
it('calls advanceJourneyPosition with correct args', async () => {
mockAdvance.mockResolvedValue(1);
await POST(makeRequest(VALID_BODY));
expect(mockAdvance).toHaveBeenCalledWith(VALID_BODY.userId, VALID_BODY.blueprintId);
});
});
+40
View File
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { advanceJourneyPosition } from '@/lib/db/queries';
import { getOptionalSession } from '@/lib/auth-helpers';
const AdvanceBodySchema = z.object({
userId: z.string().uuid().optional(),
blueprintId: z.string().uuid(),
});
/**
* POST /api/advance
*
* Advances the learner's journey position for a given blueprint.
* Called when all checkpoints in a lesson are mastered.
* Creates the journey instance on first call (anonymous-session-first).
*/
export async function POST(req: NextRequest) {
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
const parsed = AdvanceBodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: 'Missing userId or blueprintId' }, { status: 400 });
}
const session = await getOptionalSession();
const userId = session?.user?.id ?? parsed.data.userId;
if (!userId) {
return NextResponse.json({ error: 'Missing userId or blueprintId' }, { status: 400 });
}
const { blueprintId } = parsed.data;
const position = await advanceJourneyPosition(userId, blueprintId);
return NextResponse.json({ position });
}
+3
View File
@@ -0,0 +1,3 @@
import { handlers } from '@/auth';
export const { GET, POST } = handlers;
+41
View File
@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { eq } from 'drizzle-orm';
import { randomBytes } from 'crypto';
import { db } from '@/lib/db';
import { users, passwordResetTokens } from '@/lib/db/schema';
import { sendEmail } from '@/lib/email';
import { passwordResetEmail } from '@/lib/email/templates';
const Schema = z.object({ email: z.string().email() });
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 = Schema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: 'Valid email required' }, { status: 400 });
// Always return 200 — don't reveal whether email exists
const [user] = await db
.select({ id: users.id })
.from(users)
.where(eq(users.email, parsed.data.email.toLowerCase()))
.limit(1);
if (user) {
const token = randomBytes(32).toString('hex');
const expires = new Date(Date.now() + 3_600_000); // 1 hour
await db
.delete(passwordResetTokens)
.where(eq(passwordResetTokens.userId, user.id));
await db.insert(passwordResetTokens).values({ token, userId: user.id, expires });
const { subject, html, text } = passwordResetEmail(token);
void sendEmail({ to: parsed.data.email, subject, html, text }).catch(() => {});
}
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,122 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
const { mockTransaction } = vi.hoisted(() => ({ mockTransaction: vi.fn() }));
vi.mock('@/lib/db', () => ({ db: { transaction: mockTransaction } }));
vi.mock('@/lib/cache/redis', () => ({
getRedis: vi.fn().mockReturnValue({
get: vi.fn().mockResolvedValue(null),
set: vi.fn().mockResolvedValue('OK'),
del: vi.fn().mockResolvedValue(1),
}),
}));
vi.mock('@/lib/auth-helpers', () => ({
getOptionalSession: vi.fn().mockResolvedValue({
user: { id: '22222222-2222-2222-2222-222222222222' },
}),
requireAuth: vi.fn(),
requireAdmin: vi.fn(),
}));
import { POST } from '../route';
import { getOptionalSession } from '@/lib/auth-helpers';
import { getRedis } from '@/lib/cache/redis';
const ANON_ID = '11111111-1111-1111-1111-111111111111';
const AUTH_ID = '22222222-2222-2222-2222-222222222222';
function makeRequest(body: unknown) {
return new NextRequest('http://localhost/api/auth/migrate-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
function setupTransaction() {
mockTransaction.mockImplementation(async (fn: (tx: unknown) => Promise<void>) => {
const tx = {
update: vi.fn().mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
}),
};
return fn(tx);
});
}
describe('POST /api/auth/migrate-session', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getOptionalSession).mockResolvedValue({ user: { id: AUTH_ID } } as never);
setupTransaction();
const redis = getRedis();
vi.mocked(redis.get).mockResolvedValue(null);
});
it('returns 401 when no session', async () => {
vi.mocked(getOptionalSession).mockResolvedValueOnce(null);
const res = await POST(makeRequest({ anonUserId: ANON_ID }));
expect(res.status).toBe(401);
});
it('returns 401 when session has no user id', async () => {
vi.mocked(getOptionalSession).mockResolvedValueOnce({ user: {} } as never);
const res = await POST(makeRequest({ anonUserId: ANON_ID }));
expect(res.status).toBe(401);
});
it('returns 400 on bad JSON', async () => {
const req = new NextRequest('http://localhost/api/auth/migrate-session', {
method: 'POST',
body: '{not json',
headers: { 'Content-Type': 'application/json' },
});
const res = await POST(req);
expect(res.status).toBe(400);
});
it('returns 400 when anonUserId not UUID', async () => {
const res = await POST(makeRequest({ anonUserId: 'not-uuid' }));
expect(res.status).toBe(400);
});
it('returns 200 no-op when anonUserId equals authUserId', async () => {
const res = await POST(makeRequest({ anonUserId: AUTH_ID }));
expect(res.status).toBe(200);
expect(mockTransaction).not.toHaveBeenCalled();
});
it('returns 200 {ok:true} and runs transaction on success', async () => {
const res = await POST(makeRequest({ anonUserId: ANON_ID }));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.ok).toBe(true);
expect(mockTransaction).toHaveBeenCalledOnce();
});
it('renames Redis budget key when it exists', async () => {
const redis = getRedis();
vi.mocked(redis.get).mockResolvedValueOnce('42');
await POST(makeRequest({ anonUserId: ANON_ID }));
expect(redis.set).toHaveBeenCalledWith(`budget:${AUTH_ID}`, '42');
expect(redis.del).toHaveBeenCalledWith(`budget:${ANON_ID}`);
});
it('skips Redis rename when anon key absent', async () => {
const redis = getRedis();
vi.mocked(redis.get).mockResolvedValueOnce(null);
await POST(makeRequest({ anonUserId: ANON_ID }));
expect(redis.set).not.toHaveBeenCalled();
});
it('returns 200 even when Redis throws (non-fatal)', async () => {
vi.mocked(getRedis).mockImplementationOnce(() => { throw new Error('Redis down'); });
const res = await POST(makeRequest({ anonUserId: ANON_ID }));
expect(res.status).toBe(200);
});
});
+69
View File
@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { responses, mastery, journeyInstances } from '@/lib/db/schema';
import { getOptionalSession } from '@/lib/auth-helpers';
import { getRedis } from '@/lib/cache/redis';
const MigrateSchema = z.object({
anonUserId: z.string().uuid(),
});
export async function POST(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 = MigrateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: 'anonUserId must be a UUID' }, { status: 400 });
}
const { anonUserId } = parsed.data;
const authUserId = session.user.id;
if (anonUserId === authUserId) {
return NextResponse.json({ ok: true });
}
await db.transaction(async (tx) => {
await tx.update(responses).set({ userId: authUserId }).where(eq(responses.userId, anonUserId));
await tx
.update(mastery)
.set({ userId: authUserId })
.where(eq(mastery.userId, anonUserId))
.catch(() => {
// Conflict if auth user already has mastery for same concept — skip
});
await tx
.update(journeyInstances)
.set({ userId: authUserId })
.where(eq(journeyInstances.userId, anonUserId))
.catch(() => {});
});
// Rename Redis budget key
try {
const r = getRedis();
const anonKey = `budget:${anonUserId}`;
const authKey = `budget:${authUserId}`;
const val = await r.get(anonKey);
if (val !== null) {
await r.set(authKey, val);
await r.del(anonKey);
}
} catch {
// Redis optional; don't fail migration
}
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,122 @@
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 } }));
vi.mock('@/lib/site-config', () => ({ isSignupsEnabled: vi.fn().mockResolvedValue(true) }));
vi.mock('bcryptjs', () => ({
default: { hash: vi.fn().mockResolvedValue('hashed_pw'), compare: vi.fn() },
}));
import { POST } from '../route';
import bcrypt from 'bcryptjs';
const VALID_BODY = { email: 'user@example.com', name: 'Test User', password: 'securepassword123' };
function makeRequest(body: unknown) {
return new NextRequest('http://localhost/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
function setupDefault({ existingUser = false } = {}) {
mockSelect.mockImplementation((fields: Record<string, unknown> | undefined) => {
if (fields && 'total' in fields) {
// count() query — first user, so total = 0
return { from: vi.fn().mockResolvedValue([{ total: 0 }]) };
}
// email duplicate check
const rows = existingUser ? [{ id: 'existing' }] : [];
return { from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue(rows) }) }) };
});
mockInsert.mockReturnValue({
values: vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue([{ id: 'new-user-uuid' }]) }),
});
}
describe('POST /api/auth/register', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(bcrypt.hash).mockResolvedValue('hashed_pw' as never);
setupDefault();
});
it('returns 400 on bad JSON', async () => {
const req = new NextRequest('http://localhost/api/auth/register', {
method: 'POST',
body: '{not json',
headers: { 'Content-Type': 'application/json' },
});
const res = await POST(req);
expect(res.status).toBe(400);
});
it('returns 400 when email missing', async () => {
const res = await POST(makeRequest({ name: 'Test', password: 'password123' }));
expect(res.status).toBe(400);
});
it('returns 400 when email invalid', async () => {
const res = await POST(makeRequest({ ...VALID_BODY, email: 'not-an-email' }));
expect(res.status).toBe(400);
});
it('returns 400 when name missing', async () => {
const res = await POST(makeRequest({ email: 'a@b.com', password: 'password123' }));
expect(res.status).toBe(400);
});
it('returns 400 when name empty', async () => {
const res = await POST(makeRequest({ ...VALID_BODY, name: '' }));
expect(res.status).toBe(400);
});
it('returns 400 when password too short', async () => {
const res = await POST(makeRequest({ ...VALID_BODY, password: 'short' }));
expect(res.status).toBe(400);
});
it('returns 400 when password over 128 chars', async () => {
const res = await POST(makeRequest({ ...VALID_BODY, password: 'a'.repeat(129) }));
expect(res.status).toBe(400);
});
it('returns 409 when email already registered', async () => {
setupDefault({ existingUser: true });
const res = await POST(makeRequest(VALID_BODY));
expect(res.status).toBe(409);
const body = await res.json();
expect(body.error).toMatch(/already registered/i);
});
it('returns 201 with userId on success', async () => {
const res = await POST(makeRequest(VALID_BODY));
expect(res.status).toBe(201);
const body = await res.json();
expect(body.userId).toBe('new-user-uuid');
});
it('hashes password with cost 12', async () => {
await POST(makeRequest(VALID_BODY));
expect(bcrypt.hash).toHaveBeenCalledWith(VALID_BODY.password, 12);
});
it('stores hashed password not plaintext', async () => {
await POST(makeRequest(VALID_BODY));
const valuesMock = vi.mocked(mockInsert).mock.results[0]?.value?.values;
expect(valuesMock).toHaveBeenCalledWith(expect.objectContaining({ passwordHash: 'hashed_pw' }));
});
it('normalizes email to lowercase', async () => {
await POST(makeRequest({ ...VALID_BODY, email: 'USER@EXAMPLE.COM' }));
const valuesMock = vi.mocked(mockInsert).mock.results[0]?.value?.values;
expect(valuesMock).toHaveBeenCalledWith(expect.objectContaining({ email: 'user@example.com' }));
});
});
+63
View File
@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import bcrypt from 'bcryptjs';
import { randomBytes } from 'crypto';
import { eq, count } from 'drizzle-orm';
import { db } from '@/lib/db';
import { users, verificationTokens } from '@/lib/db/schema';
import { sendEmail } from '@/lib/email';
import { verificationEmail } from '@/lib/email/templates';
import { isSignupsEnabled } from '@/lib/site-config';
const RegisterSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(255),
password: z.string().min(8).max(128),
});
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 signupsEnabled = await isSignupsEnabled();
if (!signupsEnabled) {
return NextResponse.json({ error: 'Signups are currently disabled' }, { status: 403 });
}
const parsed = RegisterSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues.map((i) => i.message).join('; ') },
{ status: 400 },
);
}
const { email, name, password } = parsed.data;
const normalizedEmail = email.toLowerCase();
const [existing] = await db.select({ id: users.id }).from(users).where(eq(users.email, normalizedEmail)).limit(1);
if (existing) {
return NextResponse.json({ error: 'Email already registered' }, { status: 409 });
}
const [{ total }] = await db.select({ total: count() }).from(users);
const role = total === 0 ? 'admin' : 'learner';
const passwordHash = await bcrypt.hash(password, 12);
const [user] = await db
.insert(users)
.values({ email: normalizedEmail, name, passwordHash, role })
.returning({ id: users.id });
const token = randomBytes(32).toString('hex');
const expires = new Date(Date.now() + 86_400_000); // 24 hours
await db.insert(verificationTokens).values({ identifier: normalizedEmail, token, expires });
const { subject, html, text } = verificationEmail(token);
void sendEmail({ to: normalizedEmail, subject, html, text }).catch(() => {});
return NextResponse.json({ userId: user.id }, { status: 201 });
}
+40
View File
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { and, eq, gt } from 'drizzle-orm';
import bcrypt from 'bcryptjs';
import { db } from '@/lib/db';
import { users, passwordResetTokens } from '@/lib/db/schema';
const Schema = z.object({
token: z.string().min(1),
password: z.string().min(8).max(128),
});
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 = Schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, { status: 400 });
}
const { token, password } = parsed.data;
const now = new Date();
const [row] = await db
.select({ userId: passwordResetTokens.userId })
.from(passwordResetTokens)
.where(and(eq(passwordResetTokens.token, token), gt(passwordResetTokens.expires, now)))
.limit(1);
if (!row) {
return NextResponse.json({ error: 'Token invalid or expired' }, { status: 400 });
}
const passwordHash = await bcrypt.hash(password, 12);
await db.update(users).set({ passwordHash }).where(eq(users.id, row.userId));
await db.delete(passwordResetTokens).where(eq(passwordResetTokens.token, token));
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,89 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
vi.mock('@/lib/generation/generate-explanation', () => ({
generateExplanation: vi.fn(),
}));
vi.mock('@/lib/budget', () => ({
checkBudget: vi.fn().mockResolvedValue(undefined),
BudgetExceededError: class BudgetExceededError extends Error {},
}));
import { POST } from '../route';
import { generateExplanation } from '@/lib/generation/generate-explanation';
import { checkBudget, BudgetExceededError } from '@/lib/budget';
const mockGenerate = vi.mocked(generateExplanation);
const mockCheckBudget = vi.mocked(checkBudget);
const VALID_BODY = {
checkpointId: '123e4567-e89b-12d3-a456-426614174000',
angle: 'explain with an analogy',
userId: 'user-abc',
};
function makeRequest(body: unknown) {
return new NextRequest('http://localhost:3000/api/explain', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
describe('POST /api/explain', () => {
beforeEach(() => {
vi.clearAllMocks();
mockCheckBudget.mockResolvedValue(undefined);
});
it('returns 400 on malformed JSON', async () => {
const req = new NextRequest('http://localhost:3000/api/explain', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{not json',
});
const res = await POST(req);
expect(res.status).toBe(400);
});
it('returns 400 when checkpointId missing', async () => {
const res = await POST(makeRequest({ angle: 'analogy', userId: 'u1' }));
expect(res.status).toBe(400);
});
it('returns 400 when checkpointId is not a UUID', async () => {
const res = await POST(makeRequest({ ...VALID_BODY, checkpointId: 'not-uuid' }));
expect(res.status).toBe(400);
});
it('returns 400 when angle is too short', async () => {
const res = await POST(makeRequest({ ...VALID_BODY, angle: 'x' }));
expect(res.status).toBe(400);
});
it('returns 400 when userId is missing', async () => {
const res = await POST(makeRequest({ checkpointId: VALID_BODY.checkpointId, angle: VALID_BODY.angle }));
expect(res.status).toBe(400);
});
it('returns 200 with explanation on success', async () => {
mockGenerate.mockResolvedValue({ explanation: 'A closure is like a backpack...' });
const res = await POST(makeRequest(VALID_BODY));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.explanation).toBe('A closure is like a backpack...');
});
it('returns 429 when budget exceeded', async () => {
mockCheckBudget.mockRejectedValue(new BudgetExceededError('over budget'));
const res = await POST(makeRequest(VALID_BODY));
expect(res.status).toBe(429);
});
it('returns 500 on unexpected error', async () => {
mockGenerate.mockRejectedValue(new Error('LLM timeout'));
const res = await POST(makeRequest(VALID_BODY));
expect(res.status).toBe(500);
});
});
+56
View File
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { generateExplanation } from '@/lib/generation/generate-explanation';
import { BudgetExceededError, checkBudget } from '@/lib/budget';
import { getOptionalSession } from '@/lib/auth-helpers';
const ExplainRequestSchema = z.object({
checkpointId: z.string().uuid('checkpointId must be a UUID'),
angle: z.string().min(3, 'angle too short').max(200, 'angle too long'),
userId: z.string().min(1).optional(),
});
/**
* POST /api/explain — generate a re-explanation from a different angle (§4 Adapt).
*
* Called when the grader returns feedback_directive: 're-explain:<angle>'.
* Uses the generator model and is budget-gated.
*/
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 = ExplainRequestSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues.map((i) => i.message).join('; ') },
{ status: 400 },
);
}
const session = await getOptionalSession();
const userId = session?.user?.id ?? parsed.data.userId;
if (!userId) {
return NextResponse.json({ error: 'userId required' }, { status: 400 });
}
const { checkpointId, angle } = parsed.data;
try {
await checkBudget(userId);
const result = await generateExplanation({ checkpointId, angle, userId });
return NextResponse.json({ explanation: result.explanation });
} catch (err) {
if (err instanceof BudgetExceededError) {
return NextResponse.json(
{ error: 'Session limit reached. Start a new session to continue.' },
{ status: 429 },
);
}
console.error('[POST /api/explain]', err);
return NextResponse.json({ error: 'Failed to generate explanation' }, { status: 500 });
}
}
+122
View File
@@ -0,0 +1,122 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
vi.mock('@/lib/verification/grader', () => ({
gradeResponse: vi.fn(),
}));
import { POST } from '../route';
import { gradeResponse } from '@/lib/verification/grader';
const mockGradeResponse = vi.mocked(gradeResponse);
const VALID_BODY = {
checkpointId: '123e4567-e89b-12d3-a456-426614174000',
userId: 'user-abc',
responseText: 'A closure is a function that retains access to its outer scope.',
};
const MOCK_GRADE_RESULT = {
responseId: 'resp-1',
gradeId: 'grade-1',
grade: {
verdict: 'mastered' as const,
rubric_hits: ['r1'],
misconception_tag: 'none',
evidence_span: 'retains access to its outer scope',
feedback_directive: 'advance',
confidence: 0.9,
},
};
function makeRequest(body: unknown) {
return new NextRequest('http://localhost:3000/api/grade', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
describe('POST /api/grade', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns 400 when checkpointId is missing', async () => {
const res = await POST(makeRequest({ userId: 'u', responseText: 'r' }));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toBeTruthy();
});
it('returns 400 when checkpointId is not a UUID', async () => {
const res = await POST(makeRequest({ ...VALID_BODY, checkpointId: 'not-a-uuid' }));
expect(res.status).toBe(400);
});
it('returns 400 when responseText is empty', async () => {
const res = await POST(makeRequest({ ...VALID_BODY, responseText: '' }));
expect(res.status).toBe(400);
});
it('returns 400 on malformed JSON', async () => {
const req = new NextRequest('http://localhost:3000/api/grade', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{not json',
});
const res = await POST(req);
expect(res.status).toBe(400);
});
it('returns 200 with grade on success', async () => {
mockGradeResponse.mockResolvedValue(MOCK_GRADE_RESULT);
const res = await POST(makeRequest(VALID_BODY));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.grade.verdict).toBe('mastered');
expect(body.responseId).toBe('resp-1');
expect(body.gradeId).toBe('grade-1');
});
it('returns 404 when checkpoint not found', async () => {
mockGradeResponse.mockRejectedValue(
new Error('Checkpoint not found: 123e4567-e89b-12d3-a456-426614174000'),
);
const res = await POST(makeRequest(VALID_BODY));
expect(res.status).toBe(404);
});
it('returns 500 on unexpected grader error', async () => {
mockGradeResponse.mockRejectedValue(new Error('DB connection lost'));
const res = await POST(makeRequest(VALID_BODY));
expect(res.status).toBe(500);
const body = await res.json();
expect(body.error).toBe('Grading failed');
});
it('passes checkpointId, userId, responseText to gradeResponse', async () => {
mockGradeResponse.mockResolvedValue(MOCK_GRADE_RESULT);
await POST(makeRequest(VALID_BODY));
expect(mockGradeResponse).toHaveBeenCalledWith(
expect.objectContaining({
checkpointId: VALID_BODY.checkpointId,
userId: VALID_BODY.userId,
responseText: VALID_BODY.responseText,
}),
);
});
it('passes selfConfidence when provided', async () => {
mockGradeResponse.mockResolvedValue(MOCK_GRADE_RESULT);
await POST(makeRequest({ ...VALID_BODY, selfConfidence: 3 }));
expect(mockGradeResponse).toHaveBeenCalledWith(
expect.objectContaining({ selfConfidence: 3 }),
);
});
it('returns 400 when selfConfidence is out of range', async () => {
const res = await POST(makeRequest({ ...VALID_BODY, selfConfidence: 5 }));
expect(res.status).toBe(400);
});
});
+61
View File
@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { gradeResponse } from '@/lib/verification/grader';
import { BudgetExceededError } from '@/lib/budget';
import { autoFlagCheckpointIfNeeded } from '@/lib/db/queries';
import { getOptionalSession } from '@/lib/auth-helpers';
const GradeRequestSchema = z.object({
checkpointId: z.string().uuid('checkpointId must be a UUID'),
userId: z.string().min(1).optional(),
responseText: z.string().min(1, 'responseText required').max(4000),
selfConfidence: z.union([z.literal(1), z.literal(2), z.literal(3)]).optional(),
});
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 = GradeRequestSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues.map((i) => i.message).join('; ') },
{ status: 400 },
);
}
const session = await getOptionalSession();
const userId = session?.user?.id ?? parsed.data.userId;
if (!userId) {
return NextResponse.json({ error: 'userId required' }, { status: 400 });
}
const { checkpointId, responseText, selfConfidence } = parsed.data;
try {
const result = await gradeResponse({ checkpointId, userId, responseText, selfConfidence });
// Auto-flag checkpoint if collective failure rate is high (§10.4, non-blocking)
void autoFlagCheckpointIfNeeded(checkpointId).catch(() => {});
return NextResponse.json({
responseId: result.responseId,
gradeId: result.gradeId,
grade: result.grade,
});
} catch (err) {
if (err instanceof BudgetExceededError) {
return NextResponse.json(
{ error: 'Session limit reached. Start a new session to continue.' },
{ status: 429 },
);
}
const message = err instanceof Error ? err.message : 'Internal error';
if (message.startsWith('Checkpoint not found')) {
return NextResponse.json({ error: message }, { status: 404 });
}
console.error('[POST /api/grade]', err);
return NextResponse.json({ error: 'Grading failed' }, { status: 500 });
}
}
@@ -0,0 +1,66 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockQuery, mockPing, mockQuit } = vi.hoisted(() => ({
mockQuery: vi.fn(),
mockPing: vi.fn(),
mockQuit: vi.fn(),
}));
vi.mock('@/lib/db', () => ({
pool: { query: mockQuery },
}));
vi.mock('ioredis', () => ({
default: vi.fn().mockImplementation(() => ({ ping: mockPing, quit: mockQuit })),
}));
import { GET } from '../route';
describe('GET /api/health', () => {
beforeEach(() => {
vi.clearAllMocks();
mockQuery.mockResolvedValue({});
mockPing.mockResolvedValue('PONG');
mockQuit.mockResolvedValue('OK');
});
it('returns 200 ok when postgres and redis healthy', async () => {
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json();
expect(body.status).toBe('ok');
expect(body.checks.postgres).toBe('ok');
expect(body.checks.redis).toBe('ok');
});
it('returns 503 degraded when postgres fails', async () => {
mockQuery.mockRejectedValue(new Error('connection refused'));
const res = await GET();
expect(res.status).toBe(503);
const body = await res.json();
expect(body.status).toBe('degraded');
expect(body.checks.postgres).toBe('error');
expect(body.checks.redis).toBe('ok');
});
it('returns 503 degraded when redis fails', async () => {
mockPing.mockRejectedValue(new Error('redis down'));
const res = await GET();
expect(res.status).toBe(503);
const body = await res.json();
expect(body.status).toBe('degraded');
expect(body.checks.postgres).toBe('ok');
expect(body.checks.redis).toBe('error');
});
it('returns 503 when both fail', async () => {
mockQuery.mockRejectedValue(new Error('pg down'));
mockPing.mockRejectedValue(new Error('redis down'));
const res = await GET();
expect(res.status).toBe(503);
const body = await res.json();
expect(body.status).toBe('degraded');
expect(body.checks.postgres).toBe('error');
expect(body.checks.redis).toBe('error');
});
});
+32
View File
@@ -0,0 +1,32 @@
import { NextResponse } from 'next/server';
import { pool } from '@/lib/db';
import Redis from 'ioredis';
export async function GET() {
const checks: Record<string, 'ok' | 'error'> = {};
// Postgres
try {
await pool.query('SELECT 1');
checks.postgres = 'ok';
} catch {
checks.postgres = 'error';
}
// Redis
try {
const redis = new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379');
await redis.ping();
await redis.quit();
checks.redis = 'ok';
} catch {
checks.redis = 'error';
}
const allOk = Object.values(checks).every((v) => v === 'ok');
return NextResponse.json(
{ status: allOk ? 'ok' : 'degraded', checks },
{ status: allOk ? 200 : 503 },
);
}
@@ -0,0 +1,79 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
vi.mock('@/lib/intent/normalize', () => ({
normalizeIntent: vi.fn(),
}));
vi.mock('@/lib/db/queries', () => ({
getPersonalizationContext: vi.fn(),
}));
vi.mock('@/lib/generation/generate-blueprint', () => ({
generateBlueprint: vi.fn().mockResolvedValue('bp-new-uuid'),
}));
import { POST } from '../route';
import { normalizeIntent } from '@/lib/intent/normalize';
import { getPersonalizationContext } from '@/lib/db/queries';
const mockNormalize = vi.mocked(normalizeIntent);
const mockPersonalize = vi.mocked(getPersonalizationContext);
function makeRequest(body: unknown) {
return new NextRequest('http://localhost:3000/api/intent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
describe('POST /api/intent', () => {
beforeEach(() => vi.clearAllMocks());
it('returns 400 when input is missing', async () => {
const res = await POST(makeRequest({ userId: 'u1' }));
expect(res.status).toBe(400);
});
it('returns intentKey and isNew without userId', async () => {
mockNormalize.mockResolvedValue({ intentKey: 'javascript-closures', blueprintId: null, isNew: true });
const res = await POST(makeRequest({ input: 'JavaScript closures' }));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.intentKey).toBe('javascript-closures');
expect(body.isNew).toBe(true);
expect(body.personalizationHint).toBeUndefined();
expect(mockPersonalize).not.toHaveBeenCalled();
});
it('includes personalizationHint when userId provided and history exists', async () => {
mockNormalize.mockResolvedValue({ intentKey: 'closures', blueprintId: 'bp-1', isNew: false });
mockPersonalize.mockResolvedValue({
weakConcepts: ['Promises'],
recentMisconceptionTags: ['closure-is-object'],
});
const res = await POST(makeRequest({ input: 'closures', userId: 'u1' }));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.personalizationHint).toContain('Promises');
expect(body.personalizationHint).toContain('closure-is-object');
});
it('omits personalizationHint when no history', async () => {
mockNormalize.mockResolvedValue({ intentKey: 'closures', blueprintId: null, isNew: true });
mockPersonalize.mockResolvedValue({ weakConcepts: [], recentMisconceptionTags: [] });
const res = await POST(makeRequest({ input: 'closures', userId: 'u1' }));
const body = await res.json();
expect(body.personalizationHint).toBeUndefined();
});
it('returns blueprintId when intent matches existing', async () => {
mockNormalize.mockResolvedValue({ intentKey: 'closures', blueprintId: 'bp-existing', isNew: false });
mockPersonalize.mockResolvedValue({ weakConcepts: [], recentMisconceptionTags: [] });
const res = await POST(makeRequest({ input: 'closures', userId: 'u1' }));
const body = await res.json();
expect(body.blueprintId).toBe('bp-existing');
expect(body.isNew).toBe(false);
});
});
+71
View File
@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { normalizeIntent } from '@/lib/intent/normalize';
import { generateBlueprint } from '@/lib/generation/generate-blueprint';
import { getPersonalizationContext } from '@/lib/db/queries';
const IntentRequestSchema = z.object({
input: z.string().min(1, 'input required').max(500),
userId: z.string().optional(),
});
export interface IntentResponse {
intentKey: string;
blueprintId: string | null;
isNew: boolean;
/** Only present when userId provided and learner has prior history. */
personalizationHint?: string;
}
function buildPersonalizationHint(ctx: { weakConcepts: string[]; recentMisconceptionTags: string[] }): string | undefined {
const parts: string[] = [];
if (ctx.weakConcepts.length > 0) {
parts.push(`Learner is weak on: ${ctx.weakConcepts.join(', ')}.`);
}
if (ctx.recentMisconceptionTags.length > 0) {
parts.push(`Recent misconception tags: ${ctx.recentMisconceptionTags.join(', ')}.`);
}
return parts.length > 0 ? parts.join(' ') : undefined;
}
/**
* POST /api/intent
* Normalizes a learner's free-text intent into a canonical intent key,
* and returns personalization context if userId is provided.
*/
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 = IntentRequestSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues.map((i) => i.message).join('; ') },
{ status: 400 },
);
}
const { input, userId } = parsed.data;
const { intentKey, blueprintId: existingBlueprintId, isNew } = await normalizeIntent(input);
let blueprintId = existingBlueprintId;
if (isNew) {
blueprintId = await generateBlueprint({ intentText: input, intentKey });
}
let personalizationHint: string | undefined;
if (userId) {
const ctx = await getPersonalizationContext(userId);
personalizationHint = buildPersonalizationHint(ctx);
}
const response: IntentResponse = { intentKey, blueprintId, isNew };
if (personalizationHint) response.personalizationHint = personalizationHint;
return NextResponse.json(response);
}
+171
View File
@@ -0,0 +1,171 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
vi.mock('@/lib/db/queries', () => ({
getLessonResponse: vi.fn(),
}));
vi.mock('@/lib/cache/lesson', () => ({
getCachedLesson: vi.fn().mockResolvedValue(null),
setCachedLesson: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('@/lib/jobs/queue', () => ({
enqueueGenerateLesson: vi.fn().mockResolvedValue('job-id'),
}));
vi.mock('@/lib/ratelimit', () => ({
getColdStartRatelimit: vi.fn().mockReturnValue(null),
}));
vi.mock('@/lib/db', () => ({
db: {
select: vi.fn(),
insert: vi.fn(),
},
}));
// Verify generateLesson is never imported by the route module
vi.mock('@/lib/generation/generate-lesson', () => ({
generateLesson: vi.fn(() => {
throw new Error('generateLesson must never be called from a route handler');
}),
}));
import { GET } from '../route';
import { getLessonResponse } from '@/lib/db/queries';
import { getCachedLesson, setCachedLesson } from '@/lib/cache/lesson';
import { enqueueGenerateLesson } from '@/lib/jobs/queue';
import { generateLesson } from '@/lib/generation/generate-lesson';
import { db } from '@/lib/db';
const mockGetLesson = vi.mocked(getLessonResponse);
const mockGetCached = vi.mocked(getCachedLesson);
const mockSetCached = vi.mocked(setCachedLesson);
const mockEnqueue = vi.mocked(enqueueGenerateLesson);
const mockGenerateLesson = vi.mocked(generateLesson);
function makeRequest(key?: string) {
const url = key
? `http://localhost:3000/api/lessons?key=${key}`
: 'http://localhost:3000/api/lessons';
return new NextRequest(url);
}
const READY_RESPONSE = {
state: 'ready' as const,
blueprintId: 'bp-1',
blueprintTitle: 'JavaScript Closures',
lesson: {
id: 'lesson-1',
segments: [
{
id: 'seg-1',
ord: 0,
body: 'A closure is a function that...',
checkpoint: { id: 'cp-1', prompt: 'Explain what a closure is.', kind: 'explain' as const },
},
],
},
};
const OUTLINE_RESPONSE = {
state: 'outline' as const,
blueprintId: 'bp-1',
blueprintTitle: 'JavaScript Closures',
outline: {
concepts: [{ name: 'What is a closure?', ord: 0 }],
estimatedMinutes: 10,
},
};
function setupDbForOutline(existingJob = false) {
const selectChain = existingJob
? Promise.resolve([{ id: 'job-1', status: 'pending' }])
: Promise.resolve([]);
vi.mocked(db.select).mockReturnValue({
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: vi.fn(() => selectChain),
})),
})),
} as unknown as ReturnType<typeof db.select>);
vi.mocked(db.insert).mockReturnValue({
values: vi.fn(() => ({
returning: vi.fn(() => Promise.resolve([{ id: 'new-job-id' }])),
})),
} as unknown as ReturnType<typeof db.insert>);
}
describe('GET /api/lessons', () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetCached.mockResolvedValue(null);
});
it('returns 400 when key is missing', async () => {
const res = await GET(makeRequest());
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toContain('key');
});
it('returns 404 when blueprint not found', async () => {
mockGetLesson.mockResolvedValue(null);
const res = await GET(makeRequest('unknown-topic'));
expect(res.status).toBe(404);
});
it('serves from Redis cache when available', async () => {
mockGetCached.mockResolvedValue(READY_RESPONSE);
const res = await GET(makeRequest('javascript-closures'));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.state).toBe('ready');
expect(mockGetLesson).not.toHaveBeenCalled();
});
it('returns 200 with ready state when lesson exists', async () => {
mockGetLesson.mockResolvedValue(READY_RESPONSE);
const res = await GET(makeRequest('javascript-closures'));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.state).toBe('ready');
expect(body.lesson.segments).toHaveLength(1);
expect(mockSetCached).toHaveBeenCalledWith('javascript-closures', READY_RESPONSE);
});
it('returns 200 outline and enqueues job on cold start', async () => {
mockGetLesson.mockResolvedValue(OUTLINE_RESPONSE);
setupDbForOutline(false);
const res = await GET(makeRequest('javascript-closures'));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.state).toBe('outline');
expect(mockEnqueue).toHaveBeenCalledOnce();
});
it('does not enqueue when job already exists', async () => {
mockGetLesson.mockResolvedValue(OUTLINE_RESPONSE);
setupDbForOutline(true);
const res = await GET(makeRequest('javascript-closures'));
expect(res.status).toBe(200);
expect(mockEnqueue).not.toHaveBeenCalled();
});
it('does NOT call generateLesson — no synchronous generation in request path', async () => {
mockGetLesson.mockResolvedValue(READY_RESPONSE);
await GET(makeRequest('javascript-closures'));
expect(mockGenerateLesson).not.toHaveBeenCalled();
});
it('response never includes reference_answer', async () => {
mockGetLesson.mockResolvedValue(READY_RESPONSE);
const res = await GET(makeRequest('javascript-closures'));
const body = JSON.stringify(await res.json());
expect(body).not.toContain('reference_answer');
expect(body).not.toContain('rubric');
});
});
+83
View File
@@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from 'next/server';
import { getLessonResponse } from '@/lib/db/queries';
import { getCachedLesson, setCachedLesson } from '@/lib/cache/lesson';
import { enqueueGenerateLesson } from '@/lib/jobs/queue';
import { getColdStartRatelimit } from '@/lib/ratelimit';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { jobs } from '@/lib/db/schema';
/**
* GET /api/lessons?key=<intentKey>
*
* Serve path: Redis → DB → outline (enqueue generate job on cold-start).
* Never generates content synchronously (invariant #4).
*/
export async function GET(req: NextRequest) {
const key = req.nextUrl.searchParams.get('key');
if (!key) {
return NextResponse.json({ error: 'Missing ?key parameter' }, { status: 400 });
}
// 1. Redis cache
const cached = await getCachedLesson(key);
if (cached?.state === 'ready') {
return NextResponse.json(cached);
}
// 2. DB — pass uid for difficulty variant selection (§13)
const uid = req.nextUrl.searchParams.get('uid') ?? undefined;
const result = await getLessonResponse(key, uid);
if (!result) {
return NextResponse.json({ error: 'Blueprint not found' }, { status: 404 });
}
// 3. Ready — warm cache and return
if (result.state === 'ready') {
setCachedLesson(key, result).catch(() => {});
return NextResponse.json(result);
}
// 4. Outline (cold-start) — rate-limit before enqueuing
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 idempotencyKey = `generate-lesson:${result.blueprintId}:v1`;
// Check if job already exists (pending or running)
const [existingJob] = await db
.select({ id: jobs.id, status: jobs.status })
.from(jobs)
.where(eq(jobs.idempotencyKey, idempotencyKey))
.limit(1);
if (!existingJob) {
// Insert job ledger row first (durable state — invariant #8)
const [jobRow] = await db
.insert(jobs)
.values({
type: 'generate-lesson',
idempotencyKey,
status: 'pending',
payloadJson: { blueprintId: result.blueprintId, intentKey: key },
})
.returning({ id: jobs.id });
// Enqueue BullMQ job (uses jobId = idempotencyKey to dedup)
await enqueueGenerateLesson({
intentKey: key,
blueprintId: result.blueprintId,
idempotencyKey: jobRow.id,
});
}
return NextResponse.json(result);
}
@@ -0,0 +1,76 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
vi.mock('@/lib/db/queries', () => ({
getMasteryMap: vi.fn(),
}));
import { GET } from '../route';
import { getMasteryMap } from '@/lib/db/queries';
const mockGet = vi.mocked(getMasteryMap);
const NOW = new Date('2026-06-21T10:00:00Z');
const LATER = new Date('2026-06-28T10:00:00Z');
const ENTRIES = [
{
conceptId: 'c1',
conceptName: 'Closures',
blueprintTitle: 'JavaScript',
score: 0.8,
lastSeen: NOW,
nextReview: LATER,
},
{
conceptId: 'c2',
conceptName: 'Promises',
blueprintTitle: 'JavaScript',
score: 0.45,
lastSeen: NOW,
nextReview: NOW,
},
];
function makeRequest(userId?: string) {
const url = userId
? `http://localhost:3000/api/mastery?userId=${userId}`
: 'http://localhost:3000/api/mastery';
return new NextRequest(url);
}
describe('GET /api/mastery', () => {
beforeEach(() => vi.clearAllMocks());
it('returns 400 when userId is missing', async () => {
const res = await GET(makeRequest());
expect(res.status).toBe(400);
});
it('returns 200 with empty groups when no mastery', async () => {
mockGet.mockResolvedValue([]);
const res = await GET(makeRequest('u1'));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.groups).toHaveLength(0);
});
it('groups entries by blueprintTitle', async () => {
mockGet.mockResolvedValue(ENTRIES);
const res = await GET(makeRequest('u1'));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.groups).toHaveLength(1);
expect(body.groups[0].blueprintTitle).toBe('JavaScript');
expect(body.groups[0].concepts).toHaveLength(2);
});
it('includes score in each concept entry', async () => {
mockGet.mockResolvedValue(ENTRIES);
const res = await GET(makeRequest('u1'));
const body = await res.json();
const concepts = body.groups[0].concepts;
expect(concepts[0].score).toBe(0.8);
expect(concepts[1].score).toBe(0.45);
});
});
+29
View File
@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from 'next/server';
import { getMasteryMap } from '@/lib/db/queries';
import { getOptionalSession } from '@/lib/auth-helpers';
/**
* GET /api/mastery?userId=<userId>
* Returns all mastery records for a user, grouped by blueprint.
*/
export async function GET(req: NextRequest): Promise<NextResponse> {
const session = await getOptionalSession();
const userId = session?.user?.id ?? req.nextUrl.searchParams.get('userId');
if (!userId) {
return NextResponse.json({ error: 'Missing ?userId parameter' }, { status: 400 });
}
const entries = await getMasteryMap(userId);
// Group by blueprintTitle for the map view
const grouped: Record<string, { blueprintTitle: string; concepts: typeof entries }> = {};
for (const entry of entries) {
if (!grouped[entry.blueprintTitle]) {
grouped[entry.blueprintTitle] = { blueprintTitle: entry.blueprintTitle, concepts: [] };
}
grouped[entry.blueprintTitle].concepts.push(entry);
}
return NextResponse.json({ groups: Object.values(grouped) });
}
@@ -0,0 +1,88 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
vi.mock('@/lib/db/queries', () => ({
insertContentReport: vi.fn(),
}));
import { POST } from '../route';
import { insertContentReport } from '@/lib/db/queries';
const mockInsert = vi.mocked(insertContentReport);
const VALID_BODY = {
checkpointId: '123e4567-e89b-12d3-a456-426614174000',
reason: 'grade_wrong' as const,
};
function makeRequest(body: unknown) {
return new NextRequest('http://localhost:3000/api/report', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
describe('POST /api/report', () => {
beforeEach(() => vi.clearAllMocks());
it('returns 400 on malformed JSON', async () => {
const req = new NextRequest('http://localhost:3000/api/report', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{not json',
});
const res = await POST(req);
expect(res.status).toBe(400);
});
it('returns 400 when checkpointId missing', async () => {
const res = await POST(makeRequest({ reason: 'grade_wrong' }));
expect(res.status).toBe(400);
});
it('returns 400 when checkpointId is not a UUID', async () => {
const res = await POST(makeRequest({ ...VALID_BODY, checkpointId: 'not-uuid' }));
expect(res.status).toBe(400);
});
it('returns 400 when reason is invalid', async () => {
const res = await POST(makeRequest({ ...VALID_BODY, reason: 'bad_reason' }));
expect(res.status).toBe(400);
});
it('returns 400 when reason missing', async () => {
const res = await POST(makeRequest({ checkpointId: VALID_BODY.checkpointId }));
expect(res.status).toBe(400);
});
it('returns 200 on success', async () => {
mockInsert.mockResolvedValue('report-id');
const res = await POST(makeRequest(VALID_BODY));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.ok).toBe(true);
});
it('returns 200 even when DB insert throws (non-fatal)', async () => {
mockInsert.mockRejectedValue(new Error('DB error'));
const res = await POST(makeRequest(VALID_BODY));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.ok).toBe(true);
});
it('accepts optional gradeId and notes', async () => {
mockInsert.mockResolvedValue('report-id');
const res = await POST(makeRequest({
...VALID_BODY,
gradeId: '223e4567-e89b-12d3-a456-426614174001',
notes: 'The grading was wrong because...',
}));
expect(res.status).toBe(200);
expect(mockInsert).toHaveBeenCalledWith(expect.objectContaining({
gradeId: '223e4567-e89b-12d3-a456-426614174001',
notes: 'The grading was wrong because...',
}));
});
});
+43
View File
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { insertContentReport } from '@/lib/db/queries';
const ReportRequestSchema = z.object({
checkpointId: z.string().uuid('checkpointId must be a UUID'),
gradeId: z.string().uuid().optional(),
reason: z.enum(['grade_wrong', 'content_error', 'other']),
notes: z.string().max(500).optional(),
});
/**
* POST /api/report — learner-submitted content report (§10.4 user-signal outer loop).
*
* Accepts contested grades and content error flags. Stored for triage and
* potential re-verification. Always returns 200 (report is best-effort;
* learner should not see submission errors).
*/
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 = ReportRequestSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues.map((i) => i.message).join('; ') },
{ status: 400 },
);
}
try {
await insertContentReport(parsed.data);
return NextResponse.json({ ok: true });
} catch (err) {
// Non-fatal: log but don't surface to learner
console.error('[POST /api/report]', err);
return NextResponse.json({ ok: true }); // always succeed from learner's perspective
}
}
@@ -0,0 +1,62 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
vi.mock('@/lib/db/queries', () => ({
getReviewCheckpoints: vi.fn(),
}));
import { GET } from '../route';
import { getReviewCheckpoints } from '@/lib/db/queries';
const mockGet = vi.mocked(getReviewCheckpoints);
const REVIEW_ITEMS = [
{
conceptId: 'c1',
conceptName: 'Closures',
score: 0.65,
checkpointId: 'cp1',
checkpointPrompt: 'Explain what a closure is.',
checkpointKind: 'explain' as const,
},
];
function makeRequest(userId?: string) {
const url = userId
? `http://localhost:3000/api/review?userId=${userId}`
: 'http://localhost:3000/api/review';
return new NextRequest(url);
}
describe('GET /api/review', () => {
beforeEach(() => vi.clearAllMocks());
it('returns 400 when userId is missing', async () => {
const res = await GET(makeRequest());
expect(res.status).toBe(400);
});
it('returns 200 with empty list when nothing due', async () => {
mockGet.mockResolvedValue([]);
const res = await GET(makeRequest('user-1'));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.checkpoints).toHaveLength(0);
});
it('returns 200 with due checkpoints', async () => {
mockGet.mockResolvedValue(REVIEW_ITEMS);
const res = await GET(makeRequest('user-1'));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.checkpoints).toHaveLength(1);
expect(body.checkpoints[0].conceptName).toBe('Closures');
expect(body.checkpoints[0].score).toBe(0.65);
});
it('passes userId to getReviewCheckpoints', async () => {
mockGet.mockResolvedValue([]);
await GET(makeRequest('user-abc'));
expect(mockGet).toHaveBeenCalledWith('user-abc');
});
});
+20
View File
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server';
import { getReviewCheckpoints } from '@/lib/db/queries';
import { getOptionalSession } from '@/lib/auth-helpers';
/**
* GET /api/review?userId=<userId>
*
* Returns due checkpoints for spaced-rep review, ordered by overdue-ness.
*/
export async function GET(req: NextRequest): Promise<NextResponse> {
const session = await getOptionalSession();
const userId = session?.user?.id ?? req.nextUrl.searchParams.get('userId');
if (!userId) {
return NextResponse.json({ error: 'Missing ?userId parameter' }, { status: 400 });
}
const checkpoints = await getReviewCheckpoints(userId);
return NextResponse.json({ checkpoints });
}
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq, inArray } from 'drizzle-orm';
import { db } from '@/lib/db';
import { segments, sourceChunks } from '@/lib/db/schema';
/**
* GET /api/segments/:id/sources
* Returns source chunks for a segment — used for lazy citation display.
* Returns docRef + first 300 chars of text per chunk (enough for context).
*/
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
): Promise<NextResponse> {
const { id } = await params;
const [seg] = await db
.select({ sourceChunkIds: segments.sourceChunkIds })
.from(segments)
.where(eq(segments.id, id))
.limit(1);
if (!seg) {
return NextResponse.json({ error: 'Segment not found' }, { status: 404 });
}
if (seg.sourceChunkIds.length === 0) {
return NextResponse.json({ sources: [] });
}
const chunkRows = await db
.select({ id: sourceChunks.id, docRef: sourceChunks.docRef, text: sourceChunks.text })
.from(sourceChunks)
.where(inArray(sourceChunks.id, seg.sourceChunkIds));
return NextResponse.json({
sources: chunkRows.map((c) => ({
id: c.id,
docRef: c.docRef,
excerpt: c.text.slice(0, 300) + (c.text.length > 300 ? '…' : ''),
})),
});
}
@@ -0,0 +1,85 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
vi.mock('@/lib/db', () => ({
db: {
select: vi.fn(),
},
}));
import { GET } from '../[id]/sources/route';
import { db } from '@/lib/db';
function makeThenableChain<T>(result: T) {
const p = Promise.resolve(result);
return Object.assign(p, {
where: vi.fn(() => Object.assign(p, { limit: vi.fn(() => p) })),
limit: vi.fn(() => p),
});
}
function makeRequest(id: string) {
return new NextRequest(`http://localhost:3000/api/segments/${id}/sources`);
}
describe('GET /api/segments/:id/sources', () => {
beforeEach(() => vi.clearAllMocks());
it('returns 404 when segment not found', async () => {
vi.mocked(db.select).mockReturnValue({
from: vi.fn(() => ({ where: vi.fn(() => ({ limit: vi.fn(() => Promise.resolve([])) })) })),
} as unknown as ReturnType<typeof db.select>);
const res = await GET(makeRequest('seg-1'), { params: Promise.resolve({ id: 'seg-1' }) });
expect(res.status).toBe(404);
});
it('returns empty sources when segment has no source chunk IDs', async () => {
let callCount = 0;
vi.mocked(db.select).mockImplementation(() => {
callCount++;
if (callCount === 1) {
return {
from: vi.fn(() => ({
where: vi.fn(() => ({ limit: vi.fn(() => Promise.resolve([{ sourceChunkIds: [] }])) })),
})),
} as unknown as ReturnType<typeof db.select>;
}
return { from: vi.fn(() => ({ where: vi.fn(() => makeThenableChain([])) })) } as unknown as ReturnType<typeof db.select>;
});
const res = await GET(makeRequest('seg-1'), { params: Promise.resolve({ id: 'seg-1' }) });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.sources).toHaveLength(0);
});
it('returns source excerpts truncated to 300 chars', async () => {
const longText = 'a'.repeat(400);
let callCount = 0;
vi.mocked(db.select).mockImplementation(() => {
callCount++;
if (callCount === 1) {
return {
from: vi.fn(() => ({
where: vi.fn(() => ({ limit: vi.fn(() => Promise.resolve([{ sourceChunkIds: ['chunk-1'] }])) })),
})),
} as unknown as ReturnType<typeof db.select>;
}
return {
from: vi.fn(() => ({
where: vi.fn(() => makeThenableChain([
{ id: 'chunk-1', docRef: 'doc/file.md', text: longText },
])),
})),
} as unknown as ReturnType<typeof db.select>;
});
const res = await GET(makeRequest('seg-1'), { params: Promise.resolve({ id: 'seg-1' }) });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.sources[0].excerpt).toHaveLength(301); // 300 + '…'
expect(body.sources[0].excerpt.endsWith('…')).toBe(true);
expect(body.sources[0].docRef).toBe('doc/file.md');
});
});
@@ -0,0 +1,67 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
vi.mock('@/lib/verification/socratic', () => ({
runSocraticProbe: vi.fn(),
}));
import { POST } from '../route';
import { runSocraticProbe } from '@/lib/verification/socratic';
const mockProbe = vi.mocked(runSocraticProbe);
const VALID_BODY = {
gradeId: '123e4567-e89b-12d3-a456-426614174000',
userId: 'user-abc',
followUpText: 'I meant that closures hold a reference to the outer scope, not a copy.',
};
function makeRequest(body: unknown) {
return new NextRequest('http://localhost:3000/api/socratic', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
describe('POST /api/socratic', () => {
beforeEach(() => vi.clearAllMocks());
it('returns 400 when gradeId is missing', async () => {
const res = await POST(makeRequest({ userId: 'u', followUpText: 'text' }));
expect(res.status).toBe(400);
});
it('returns 400 when gradeId is not a UUID', async () => {
const res = await POST(makeRequest({ ...VALID_BODY, gradeId: 'not-a-uuid' }));
expect(res.status).toBe(400);
});
it('returns 400 when followUpText is empty', async () => {
const res = await POST(makeRequest({ ...VALID_BODY, followUpText: '' }));
expect(res.status).toBe(400);
});
it('returns 200 with reply and upgradedVerdict on success', async () => {
mockProbe.mockResolvedValue({ reply: 'Yes, that is correct.', upgradedVerdict: 'mastered' });
const res = await POST(makeRequest(VALID_BODY));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.reply).toBe('Yes, that is correct.');
expect(body.upgradedVerdict).toBe('mastered');
});
it('returns 404 when grade not found', async () => {
mockProbe.mockRejectedValue(
new Error(`Grade not found: ${VALID_BODY.gradeId}`),
);
const res = await POST(makeRequest(VALID_BODY));
expect(res.status).toBe(404);
});
it('returns 500 on unexpected error', async () => {
mockProbe.mockRejectedValue(new Error('DB down'));
const res = await POST(makeRequest(VALID_BODY));
expect(res.status).toBe(500);
});
});
+53
View File
@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { runSocraticProbe } from '@/lib/verification/socratic';
import { BudgetExceededError } from '@/lib/budget';
import { getOptionalSession } from '@/lib/auth-helpers';
const SocraticRequestSchema = z.object({
gradeId: z.string().uuid('gradeId must be a UUID'),
userId: z.string().min(1).optional(),
followUpText: z.string().min(1, 'followUpText required').max(2000),
});
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 = SocraticRequestSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues.map((i) => i.message).join('; ') },
{ status: 400 },
);
}
const session = await getOptionalSession();
const userId = session?.user?.id ?? parsed.data.userId;
if (!userId) {
return NextResponse.json({ error: 'userId required' }, { status: 400 });
}
const { gradeId, followUpText } = parsed.data;
try {
const result = await runSocraticProbe({ gradeId, userId, followUpText });
return NextResponse.json(result);
} catch (err) {
if (err instanceof BudgetExceededError) {
return NextResponse.json(
{ error: 'Session limit reached. Start a new session to continue.' },
{ status: 429 },
);
}
const message = err instanceof Error ? err.message : 'Internal error';
if (message.startsWith('Grade not found')) {
return NextResponse.json({ error: message }, { status: 404 });
}
console.error('[POST /api/socratic]', err);
return NextResponse.json({ error: 'Socratic probe failed' }, { status: 500 });
}
}
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
vi.mock('@/lib/generation/generate-tangent', () => ({
generateTangent: vi.fn(),
}));
vi.mock('@/lib/budget', () => ({
checkBudget: vi.fn().mockResolvedValue(undefined),
recordUsage: vi.fn().mockResolvedValue(undefined),
BudgetExceededError: class BudgetExceededError extends Error {},
}));
import { POST } from '../route';
import { generateTangent } from '@/lib/generation/generate-tangent';
import { checkBudget, BudgetExceededError } from '@/lib/budget';
const mockGenerate = vi.mocked(generateTangent);
const mockCheckBudget = vi.mocked(checkBudget);
const VALID_BODY = {
segmentId: '123e4567-e89b-12d3-a456-426614174000',
question: 'Why does this work?',
userId: 'user-abc',
};
function makeRequest(body: unknown) {
return new NextRequest('http://localhost:3000/api/tangent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
describe('POST /api/tangent', () => {
beforeEach(() => {
vi.clearAllMocks();
mockCheckBudget.mockResolvedValue(undefined);
});
it('returns 400 on malformed JSON', async () => {
const req = new NextRequest('http://localhost:3000/api/tangent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{not json',
});
const res = await POST(req);
expect(res.status).toBe(400);
});
it('returns 400 when segmentId missing', async () => {
const res = await POST(makeRequest({ question: 'Why?', userId: 'u1' }));
expect(res.status).toBe(400);
});
it('returns 400 when segmentId is not a UUID', async () => {
const res = await POST(makeRequest({ ...VALID_BODY, segmentId: 'not-uuid' }));
expect(res.status).toBe(400);
});
it('returns 400 when question is too short', async () => {
const res = await POST(makeRequest({ ...VALID_BODY, question: 'x' }));
expect(res.status).toBe(400);
});
it('returns 400 when userId missing', async () => {
const res = await POST(makeRequest({ segmentId: VALID_BODY.segmentId, question: VALID_BODY.question }));
expect(res.status).toBe(400);
});
it('returns 200 with reply on success', async () => {
mockGenerate.mockResolvedValue({ reply: 'Great question! This works because...' });
const res = await POST(makeRequest(VALID_BODY));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.reply).toBe('Great question! This works because...');
});
it('calls generateTangent with correct args', async () => {
mockGenerate.mockResolvedValue({ reply: 'answer' });
await POST(makeRequest(VALID_BODY));
expect(mockGenerate).toHaveBeenCalledWith({
segmentId: VALID_BODY.segmentId,
question: VALID_BODY.question,
userId: VALID_BODY.userId,
});
});
it('returns 429 when budget exceeded', async () => {
mockCheckBudget.mockRejectedValue(new BudgetExceededError('over budget'));
const res = await POST(makeRequest(VALID_BODY));
expect(res.status).toBe(429);
});
it('returns 500 on unexpected error', async () => {
mockGenerate.mockRejectedValue(new Error('LLM timeout'));
const res = await POST(makeRequest(VALID_BODY));
expect(res.status).toBe(500);
});
});
+52
View File
@@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { generateTangent } from '@/lib/generation/generate-tangent';
import { BudgetExceededError } from '@/lib/budget';
import { checkBudget, recordUsage } from '@/lib/budget';
import { getOptionalSession } from '@/lib/auth-helpers';
const TangentRequestSchema = z.object({
segmentId: z.string().uuid('segmentId must be a UUID'),
question: z.string().min(3, 'question too short').max(500, 'question too long'),
userId: z.string().min(1).optional(),
});
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 = TangentRequestSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues.map((i) => i.message).join('; ') },
{ status: 400 },
);
}
const session = await getOptionalSession();
const userId = session?.user?.id ?? parsed.data.userId;
if (!userId) {
return NextResponse.json({ error: 'userId required' }, { status: 400 });
}
const { segmentId, question } = parsed.data;
try {
await checkBudget(userId);
const result = await generateTangent({ segmentId, question, userId });
void recordUsage(userId, 200).catch(() => {}); // rough estimate; actual usage tracked in generateTangent trace
return NextResponse.json({ reply: result.reply });
} catch (err) {
if (err instanceof BudgetExceededError) {
return NextResponse.json(
{ error: 'Session limit reached. Start a new session to continue.' },
{ status: 429 },
);
}
console.error('[POST /api/tangent]', err);
return NextResponse.json({ error: 'Failed to generate tangent explanation' }, { status: 500 });
}
}
@@ -0,0 +1,67 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
import { requireAuth } from '@/lib/auth-helpers';
const mockTx = {
delete: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
};
const mockDb = {
transaction: vi.fn().mockImplementation(async (fn: (tx: typeof mockTx) => Promise<void>) => fn(mockTx)),
};
vi.mock('@/lib/db', () => ({ db: mockDb }));
vi.mock('@/lib/db/schema', () => ({
users: {},
responses: {},
mastery: {},
journeyInstances: {},
}));
vi.mock('drizzle-orm', () => ({
eq: vi.fn((col, val) => ({ col, val })),
}));
function makeRequest(): NextRequest {
return new NextRequest('http://localhost/api/user/account', { method: 'DELETE' });
}
describe('DELETE /api/user/account', () => {
beforeEach(() => {
vi.clearAllMocks();
mockTx.delete.mockReturnValue({ where: vi.fn().mockResolvedValue([]) });
mockDb.transaction.mockImplementation(async (fn: (tx: typeof mockTx) => Promise<void>) => fn(mockTx));
});
it('returns 200 {ok:true} on happy path', async () => {
const { DELETE } = await import('../route');
const res = await DELETE();
const body = await res.json();
expect(res.status).toBe(200);
expect(body).toEqual({ ok: true });
});
it('runs transaction and deletes all four tables', async () => {
const { DELETE } = await import('../route');
await DELETE();
expect(mockDb.transaction).toHaveBeenCalledOnce();
expect(mockTx.delete).toHaveBeenCalledTimes(4);
});
it('returns 401 when requireAuth throws', async () => {
vi.mocked(requireAuth).mockRejectedValueOnce(new Error('redirect'));
const { DELETE } = await import('../route');
await expect(DELETE()).rejects.toThrow('redirect');
});
it('propagates transaction errors', async () => {
mockDb.transaction.mockRejectedValueOnce(new Error('db error'));
const { DELETE } = await import('../route');
await expect(DELETE()).rejects.toThrow('db error');
});
});
+22
View File
@@ -0,0 +1,22 @@
import { NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { users, responses, mastery, journeyInstances } from '@/lib/db/schema';
import { requireAuth } from '@/lib/auth-helpers';
export async function DELETE(): Promise<NextResponse> {
const session = await requireAuth();
const userId = session.user!.id!;
await db.transaction(async (tx) => {
await tx.delete(responses).where(eq(responses.userId, userId));
await tx.delete(mastery).where(eq(mastery.userId, userId));
await tx.delete(journeyInstances).where(eq(journeyInstances.userId, userId));
// accounts and sessions ON DELETE CASCADE from users FK
await tx.delete(users).where(eq(users.id, userId));
});
// Session cookies become invalid once the session row is gone (cascaded above).
// Client must call signOut() after this response.
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,115 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
const { mockSelect, mockUpdate } = vi.hoisted(() => ({
mockSelect: vi.fn(),
mockUpdate: vi.fn(),
}));
vi.mock('@/lib/db', () => ({ db: { select: mockSelect, update: mockUpdate } }));
vi.mock('bcryptjs', () => ({
default: { hash: vi.fn().mockResolvedValue('new_hash'), compare: vi.fn().mockResolvedValue(true) },
}));
import { POST } from '../route';
import bcrypt from 'bcryptjs';
import { requireAuth } from '@/lib/auth-helpers';
function makeRequest(body: unknown) {
return new NextRequest('http://localhost/api/user/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
function setupDefault() {
mockSelect.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([{ passwordHash: 'existing_hash' }]) }),
}),
});
mockUpdate.mockReturnValue({ set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) });
}
describe('POST /api/user/change-password', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(bcrypt.hash).mockResolvedValue('new_hash' as never);
vi.mocked(bcrypt.compare).mockResolvedValue(true as never);
setupDefault();
});
it('returns 200 {ok:true} on success', async () => {
const res = await POST(makeRequest({ oldPassword: 'old123456', newPassword: 'new123456' }));
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ok: true });
});
it('calls bcrypt.compare with old password and existing hash', async () => {
await POST(makeRequest({ oldPassword: 'old123456', newPassword: 'new123456' }));
expect(bcrypt.compare).toHaveBeenCalledWith('old123456', 'existing_hash');
});
it('calls bcrypt.hash on new password', async () => {
await POST(makeRequest({ oldPassword: 'old123456', newPassword: 'new123456' }));
expect(bcrypt.hash).toHaveBeenCalledWith('new123456', 12);
});
it('returns 400 on bad JSON', async () => {
const req = new NextRequest('http://localhost/api/user/change-password', {
method: 'POST',
body: '{bad',
headers: { 'Content-Type': 'application/json' },
});
expect((await POST(req)).status).toBe(400);
});
it('returns 400 when oldPassword missing', async () => {
expect((await POST(makeRequest({ newPassword: 'new123456' }))).status).toBe(400);
});
it('returns 400 when newPassword missing', async () => {
expect((await POST(makeRequest({ oldPassword: 'old123456' }))).status).toBe(400);
});
it('returns 400 when newPassword too short', async () => {
expect((await POST(makeRequest({ oldPassword: 'old123456', newPassword: 'short' }))).status).toBe(400);
});
it('returns 400 when user has no password (OAuth account)', async () => {
mockSelect.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([{ passwordHash: null }]) }),
}),
});
const res = await POST(makeRequest({ oldPassword: 'old123456', newPassword: 'new123456' }));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/no password/i);
});
it('returns 400 when user not found', async () => {
mockSelect.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }),
}),
});
const res = await POST(makeRequest({ oldPassword: 'old123456', newPassword: 'new123456' }));
expect(res.status).toBe(400);
});
it('returns 400 when old password wrong', async () => {
vi.mocked(bcrypt.compare).mockResolvedValueOnce(false as never);
const res = await POST(makeRequest({ oldPassword: 'wrong', newPassword: 'new123456' }));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/incorrect/i);
});
it('propagates requireAuth rejection', async () => {
vi.mocked(requireAuth).mockRejectedValueOnce(new Error('redirect'));
await expect(POST(makeRequest({ oldPassword: 'old', newPassword: 'new12345' }))).rejects.toThrow('redirect');
});
});
+33
View File
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import bcrypt from 'bcryptjs';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { users } from '@/lib/db/schema';
import { requireAuth } from '@/lib/auth-helpers';
const Schema = z.object({
oldPassword: z.string().min(1),
newPassword: z.string().min(8).max(128),
});
export async function POST(req: NextRequest): Promise<NextResponse> {
const session = await requireAuth();
const userId = session.user!.id!;
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 [user] = await db.select({ passwordHash: users.passwordHash }).from(users).where(eq(users.id, userId)).limit(1);
if (!user?.passwordHash) return NextResponse.json({ error: 'No password set on this account' }, { status: 400 });
const valid = await bcrypt.compare(parsed.data.oldPassword, user.passwordHash);
if (!valid) return NextResponse.json({ error: 'Current password is incorrect' }, { status: 400 });
const newHash = await bcrypt.hash(parsed.data.newPassword, 12);
await db.update(users).set({ passwordHash: newHash }).where(eq(users.id, userId));
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,139 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
vi.mock('@/lib/db', () => ({
db: {
update: vi.fn(),
},
}));
import { PATCH } from '../route';
import { db } from '@/lib/db';
import { requireAuth } from '@/lib/auth-helpers';
function makeRequest(body: unknown, options: RequestInit = {}): NextRequest {
return new NextRequest('http://localhost/api/user/profile', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
...options,
});
}
function makeBadJsonRequest(): NextRequest {
return new NextRequest('http://localhost/api/user/profile', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: 'not valid json{{{',
});
}
function setupUpdateMock() {
vi.mocked(db).update = vi.fn().mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
});
}
describe('PATCH /api/user/profile', () => {
beforeEach(() => {
vi.clearAllMocks();
setupUpdateMock();
});
it('returns 200 with {ok:true} on valid name update', async () => {
const res = await PATCH(makeRequest({ name: 'New Name' }));
expect(res.status).toBe(200);
const json = await res.json();
expect(json).toEqual({ ok: true });
});
it('returns 200 with {ok:true} on valid image update', async () => {
const res = await PATCH(makeRequest({ image: 'https://example.com/avatar.png' }));
expect(res.status).toBe(200);
const json = await res.json();
expect(json).toEqual({ ok: true });
});
it('returns 200 with {ok:true} on updating both name and image', async () => {
const res = await PATCH(makeRequest({ name: 'Jane', image: 'https://example.com/jane.png' }));
expect(res.status).toBe(200);
const json = await res.json();
expect(json).toEqual({ ok: true });
});
it('calls db.update with the parsed data and correct user id', async () => {
const mockWhere = vi.fn().mockResolvedValue([]);
const mockSet = vi.fn().mockReturnValue({ where: mockWhere });
vi.mocked(db).update = vi.fn().mockReturnValue({ set: mockSet });
await PATCH(makeRequest({ name: 'Alice' }));
expect(vi.mocked(db).update).toHaveBeenCalledOnce();
expect(mockSet).toHaveBeenCalledWith({ name: 'Alice' });
expect(mockWhere).toHaveBeenCalledOnce();
});
it('returns 400 on invalid JSON body', async () => {
const res = await PATCH(makeBadJsonRequest());
expect(res.status).toBe(400);
const json = await res.json();
expect(json).toHaveProperty('error');
expect(json.error).toMatch(/invalid json/i);
});
it('returns 400 when name is empty string', async () => {
const res = await PATCH(makeRequest({ name: '' }));
expect(res.status).toBe(400);
const json = await res.json();
expect(json).toHaveProperty('error');
});
it('returns 400 when image is not a valid URL', async () => {
const res = await PATCH(makeRequest({ image: 'not-a-url' }));
expect(res.status).toBe(400);
const json = await res.json();
expect(json).toHaveProperty('error');
});
it('returns 400 when name exceeds max length', async () => {
const res = await PATCH(makeRequest({ name: 'a'.repeat(256) }));
expect(res.status).toBe(400);
const json = await res.json();
expect(json).toHaveProperty('error');
});
it('returns 400 when image URL exceeds max length', async () => {
const res = await PATCH(makeRequest({ image: 'https://example.com/' + 'a'.repeat(2048) }));
expect(res.status).toBe(400);
const json = await res.json();
expect(json).toHaveProperty('error');
});
it('returns 401 when requireAuth throws (unauthenticated)', async () => {
vi.mocked(requireAuth).mockRejectedValueOnce(new Error('redirect'));
await expect(PATCH(makeRequest({ name: 'Test' }))).rejects.toThrow('redirect');
});
it('does not call db.update when validation fails', async () => {
await PATCH(makeRequest({ name: '' }));
expect(vi.mocked(db).update).not.toHaveBeenCalled();
});
it('does not call db.update when JSON is invalid', async () => {
await PATCH(makeBadJsonRequest());
expect(vi.mocked(db).update).not.toHaveBeenCalled();
});
it('propagates db errors', async () => {
vi.mocked(db).update = vi.fn().mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockRejectedValue(new Error('DB failure')),
}),
});
await expect(PATCH(makeRequest({ name: 'Bob' }))).rejects.toThrow('DB failure');
});
});
+25
View File
@@ -0,0 +1,25 @@
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 { requireAuth } from '@/lib/auth-helpers';
const ProfileSchema = z.object({
name: z.string().min(1).max(255).optional(),
image: z.string().url().max(2048).optional(),
});
export async function PATCH(req: NextRequest): Promise<NextResponse> {
const session = await requireAuth();
const userId = session.user!.id!;
let body: unknown;
try { body = await req.json(); } catch { return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); }
const parsed = ProfileSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, { status: 400 });
await db.update(users).set(parsed.data).where(eq(users.id, userId));
return NextResponse.json({ ok: true });
}
+65
View File
@@ -0,0 +1,65 @@
'use client';
import { useSearchParams } from 'next/navigation';
import Link from 'next/link';
const ERROR_MESSAGES: Record<string, string> = {
Configuration: 'Server configuration error. Contact support.',
AccessDenied: 'Access denied.',
Verification: 'Verification link expired or already used. Request a new one.',
OAuthSignin: 'Could not connect to the OAuth provider. Try again.',
OAuthCallback: 'OAuth callback error. Try again.',
OAuthCreateAccount: 'Could not create account via OAuth.',
EmailCreateAccount: 'Could not create account with that email.',
Callback: 'Authentication callback error.',
OAuthAccountNotLinked: 'This email is already registered with a different sign-in method.',
EmailSignin: 'Email sign-in failed.',
CredentialsSignin: 'Invalid email or password.',
SessionRequired: 'You must be signed in to access that page.',
Default: 'An authentication error occurred.',
};
export default function AuthErrorPage() {
const params = useSearchParams();
const code = params.get('error') ?? 'Default';
const message = ERROR_MESSAGES[code] ?? ERROR_MESSAGES.Default;
return (
<main
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100svh',
padding: 'var(--space-8)',
gap: 'var(--space-4)',
textAlign: 'center',
}}
>
<h1
style={{
fontFamily: 'var(--font-display)',
fontSize: '1.5rem',
fontWeight: 500,
color: 'var(--color-ink)',
}}
>
Sign-in error
</h1>
<p style={{ color: 'var(--color-misconception)', maxWidth: '30rem' }}>{message}</p>
<Link
href="/auth/login"
style={{
color: 'var(--color-accent)',
fontFamily: 'var(--font-display)',
fontSize: '0.9375rem',
textDecoration: 'none',
marginTop: 'var(--space-2)',
}}
>
Back to sign in
</Link>
</main>
);
}

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