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:
+107
@@ -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 ≈ 3–4 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
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals", "next/typescript"]
|
||||
}
|
||||
+19
@@ -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*
|
||||
@@ -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 (T0–T2) + 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 (~62–68ch) 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
@@ -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 = 2–3 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.
|
||||
@@ -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 (T0–T2) + 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
|
||||
@@ -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:
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Enable pgvector extension (binary is pre-installed in pgvector/pgvector image)
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
@@ -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 2–4 **segments**.
|
||||
|
||||
```
|
||||
Lesson (~10 min, bounded, one sitting)
|
||||
├─ Segment 1: short readable explanation (~300–500 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* (2–4 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 (~62–68ch) 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 2–3 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 ≈ 2–4 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 (2–3 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.
|
||||
@@ -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)
|
||||
```
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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'));
|
||||
@@ -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;
|
||||
Vendored
+6
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Generated
+7186
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
msgpackr-extract: true
|
||||
sharp: true
|
||||
unrs-resolver: true
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('prettier').Config} */
|
||||
export default {
|
||||
semi: true,
|
||||
singleQuote: true,
|
||||
trailingComma: 'all',
|
||||
printWidth: 100,
|
||||
tabWidth: 2,
|
||||
};
|
||||
+185
@@ -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;
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { handlers } from '@/auth';
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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' }));
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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...',
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user