Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 51 additions & 11 deletions api/learning.mjs
Original file line number Diff line number Diff line change
@@ -1,19 +1,59 @@
// Consolidated handler — routes via ?action= (see shared/api/learning-registry.mjs).
import { HANDLER_MODULES, LEARNING_ACTIONS } from '../shared/api/learning-registry.mjs';
// Leftover local dispatcher — same Fetch handlers as production.
// Production: functions/api/[[path]].js → dispatchLearningAction.
import { requireAuth } from './auth/verify.mjs';
import { AUTH_ACTIONS } from '../shared/api/learning-registry.mjs';
import { dispatchLearningAction } from '../shared/api/worker-learning.mjs';
import { getDb } from '../shared/db/client.mjs';

function json(data, init = {}) {
const headers = new Headers(init.headers);
headers.set('content-type', 'application/json; charset=utf-8');
return new Response(JSON.stringify(data ?? {}), { ...init, headers });
}

function toFetchRequest(req) {
const url = new URL(req.originalUrl || req.url || '/api/learning', 'http://localhost');
for (const [key, value] of Object.entries(req.query || {})) {
if (value == null) continue;
url.searchParams.set(key, String(value));
}
const headers = new Headers();
const incoming = req.headers || {};
if (incoming.authorization) headers.set('authorization', incoming.authorization);
if (incoming.cookie) headers.set('cookie', incoming.cookie);
const method = req.method || 'GET';
const hasBody = method !== 'GET' && method !== 'HEAD';
if (hasBody) {
headers.set('content-type', incoming['content-type'] || 'application/json');
}
return new Request(url, {
method,
headers,
body: hasBody ? JSON.stringify(req.body ?? {}) : undefined,
});
}

export default async function handler(req, res) {
const action = req.query?.action;
if (!action || !LEARNING_ACTIONS.includes(action)) {
return res.status(400).json({
error: `Unknown action. Expected one of: ${LEARNING_ACTIONS.join(', ')}`,
});
let user = req._authenticatedUser || null;
if (!user && AUTH_ACTIONS.includes(action)) {
user = await requireAuth(req, res);
if (!user) return;
}

const loader = HANDLER_MODULES[action];
if (!loader) {
return res.status(500).json({ error: `No handler module for action: ${action}` });
let client = null;
try {
client = getDb();
} catch {
client = null;
}

const mod = await loader();
return mod.default(req, res);
const response = await dispatchLearningAction({
request: toFetchRequest(req),
client,
user,
json,
});
const payload = await response.json();
return res.status(response.status).json(payload);
}
2 changes: 1 addition & 1 deletion docs/architecture/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ to prod.
```
src/ React SPA (pages, components, hooks, data, lib, adapters)
api/ Legacy local handlers (.mjs) — kept for local dev parity
handlers/ Action handlers used by both api/ and functions/
handlers/ Fetch-style action handlers used by dispatchLearningAction
functions/api/ Cloudflare Pages Functions (production catch-all)
shared/ Code shared between api/ and functions/ (db, lib, handlers, fixtures)
scripts/ Content pipelines + env validation + deploy helpers
Expand Down
2 changes: 1 addition & 1 deletion docs/development/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ route set. The `api/*.mjs` handlers are dev/legacy only and are not deployed.
| `/api/chat` | `vite-plugin-local-ai.js` streams CLIs | Not served (client still calls it) |
| `/api/chats`, `/api/notes` | In-memory Vite stubs | Not served |
| `/api/progress`, `/api/auth/*` | In-memory Vite stubs | Pages Function → D1 |
| `/api/learning?action=…` | Legacy `api/learning.mjs` → `handlers/` | Pages Function → `handlers/` (via `shared/`) |
| `/api/learning?action=…` | Legacy `api/learning.mjs` → `dispatchLearningAction` → Fetch `handlers/` | Pages Function → `dispatchLearningAction` → Fetch `handlers/` |
| `/api/learning/reader`, `/api/ai` | (dev stubs / static) | Pages Function |

`tag` is a `/api/learning?action=tag` action, not a top-level `/api/tag`
Expand Down
7 changes: 7 additions & 0 deletions docs/knowledge/learnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ Reusable lessons that are not obvious from the code. Add new entries at the
top with a date. One lesson per bullet; link to the code or ADR that
exemplifies it.

## 2026-08 — Learning actions are Fetch handlers, not Express

Production already authenticates in the Pages Function and
`dispatchLearningAction`. The Express `(req, res)` adapter was leftover from
Vercel — handlers now take `{ request, user, json }` and return `json(...)`.
Do not reintroduce a second Express dispatcher for `/api/learning`.

## 2026-07 — Broad curriculum coverage needs a machine-readable contract

Track names alone cannot prove that a broad learning taxonomy is actually
Expand Down
26 changes: 13 additions & 13 deletions handlers/activity.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { randomBytes } from 'node:crypto';

import { readJsonBody } from '../shared/api/read-json.mjs';
import { getDb } from '../shared/db/client.mjs';
import { initDatabase } from '../shared/db/schema.mjs';
import { requireAuth } from '../api/auth/verify.mjs';
import { randomBytes } from 'node:crypto';

let initialized = false;
async function ensureInit() {
Expand All @@ -11,15 +12,14 @@ async function ensureInit() {
}
}

export default async function handler(req, res) {
export default async function handler({ request, user, json }) {
await ensureInit();
const user = await requireAuth(req, res);
if (!user) return;
if (!user) return json({ error: 'Unauthorized' }, { status: 401 });
const db = getDb();

if (req.method === 'POST') {
const { kind, problemId, conceptIds, durationMs, payload } = req.body || {};
if (!kind) return res.status(400).json({ error: 'kind required' });
if (request.method === 'POST') {
const { kind, problemId, conceptIds, durationMs, payload } = await readJsonBody(request);
if (!kind) return json({ error: 'kind required' }, { status: 400 });
const id = randomBytes(16).toString('hex');
await db.execute({
sql: `INSERT INTO activity_log (id, user_id, kind, problem_id, concept_ids, duration_ms, payload)
Expand All @@ -34,11 +34,11 @@ export default async function handler(req, res) {
payload ? JSON.stringify(payload) : null,
],
});
return res.status(200).json({ id });
return json({ id });
}

if (req.method === 'GET') {
const days = parseInt(req.query.days || '7', 10);
if (request.method === 'GET') {
const days = parseInt(new URL(request.url).searchParams.get('days') || '7', 10);
const since = new Date(Date.now() - days * 86400000).toISOString();
const result = await db.execute({
sql: `SELECT id, kind, problem_id, concept_ids, duration_ms, payload, created_at
Expand All @@ -54,8 +54,8 @@ export default async function handler(req, res) {
payload: r.payload ? JSON.parse(r.payload) : null,
createdAt: r.created_at,
}));
return res.status(200).json({ activity: rows });
return json({ activity: rows });
}

return res.status(405).json({ error: 'Method not allowed' });
return json({ error: 'Method not allowed' }, { status: 405 });
}
21 changes: 10 additions & 11 deletions handlers/artifacts.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { randomBytes } from 'node:crypto';

import { requireAuth } from '../api/auth/verify.mjs';
import { readJsonBody } from '../shared/api/read-json.mjs';
import { getDb } from '../shared/db/client.mjs';
import { initDatabase } from '../shared/db/schema.mjs';

Expand All @@ -23,25 +23,24 @@ function toEntry(row) {
};
}

export default async function handler(req, res) {
export default async function handler({ request, user, json }) {
await ensureInit();
const user = await requireAuth(req, res);
if (!user) return;
if (!user) return json({ error: 'Unauthorized' }, { status: 401 });
const db = getDb();

if (req.method === 'GET') {
if (request.method === 'GET') {
const r = await db.execute({
sql: 'SELECT * FROM user_artifacts WHERE user_id = ?',
args: [user.id],
});
const artifacts = {};
for (const row of r.rows) artifacts[row.artifact_id] = toEntry(row);
return res.status(200).json({ artifacts });
return json({ artifacts });
}

if (req.method === 'POST') {
const { artifactId, status, url, path, notes, criteria } = req.body || {};
if (!artifactId) return res.status(400).json({ error: 'artifactId required' });
if (request.method === 'POST') {
const { artifactId, status, url, path, notes, criteria } = await readJsonBody(request);
if (!artifactId) return json({ error: 'artifactId required' }, { status: 400 });
await db.execute({
sql: `INSERT INTO user_artifacts (id, user_id, artifact_id, status, url, path, notes, criteria_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
Expand All @@ -63,8 +62,8 @@ export default async function handler(req, res) {
criteria ? JSON.stringify(criteria) : null,
],
});
return res.status(200).json({ ok: true });
return json({ ok: true });
}

return res.status(405).json({ error: 'Method not allowed' });
return json({ error: 'Method not allowed' }, { status: 405 });
}
35 changes: 18 additions & 17 deletions handlers/concepts.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { randomBytes } from 'node:crypto';

import { readJsonBody } from '../shared/api/read-json.mjs';
import { getDb } from '../shared/db/client.mjs';
import { initDatabase } from '../shared/db/schema.mjs';
import { requireAuth } from '../api/auth/verify.mjs';
import { reviewConcept, masteryConfidence } from '../shared/lib/fsrs.mjs';
import { randomBytes } from 'node:crypto';
import { masteryConfidence, reviewConcept } from '../shared/lib/fsrs.mjs';

/**
* Snake_case DB/FSRS row → the camelCase shape `useConcepts` expects.
Expand Down Expand Up @@ -75,13 +76,12 @@ async function upsertMastery(db, userId, conceptId, row) {
});
}

export default async function handler(req, res) {
export default async function handler({ request, user, json }) {
await ensureInit();
const user = await requireAuth(req, res);
if (!user) return;
if (!user) return json({ error: 'Unauthorized' }, { status: 401 });
const db = getDb();

if (req.method === 'GET') {
if (request.method === 'GET') {
const r = await db.execute({
sql: 'SELECT * FROM concept_mastery WHERE user_id = ?',
args: [user.id],
Expand All @@ -91,22 +91,23 @@ export default async function handler(req, res) {
for (const row of r.rows) {
mastery[row.concept_id] = toClient(row, now);
}
return res.status(200).json({ mastery });
return json({ mastery });
}

if (req.method === 'POST') {
const { conceptId, rating } = req.body || {};
if (!conceptId || !rating) return res.status(400).json({ error: 'conceptId, rating required' });
if (request.method === 'POST') {
const { conceptId, rating } = await readJsonBody(request);
if (!conceptId || !rating)
return json({ error: 'conceptId, rating required' }, { status: 400 });
const prev = await getMastery(db, user.id, conceptId);
const next = reviewConcept(prev, rating);
await upsertMastery(db, user.id, conceptId, next);
return res.status(200).json({ mastery: toClient(next) });
return json({ mastery: toClient(next) });
}

if (req.method === 'PUT') {
if (request.method === 'PUT') {
// Bulk update from tagger: [{conceptId, rating}]
const { updates } = req.body || {};
if (!Array.isArray(updates)) return res.status(400).json({ error: 'updates array required' });
const { updates } = await readJsonBody(request);
if (!Array.isArray(updates)) return json({ error: 'updates array required' }, { status: 400 });
const results = [];
for (const u of updates) {
if (!u.conceptId || !u.rating) continue;
Expand All @@ -115,8 +116,8 @@ export default async function handler(req, res) {
await upsertMastery(db, user.id, u.conceptId, next);
results.push({ conceptId: u.conceptId, mastery: toClient(next) });
}
return res.status(200).json({ results });
return json({ results });
}

return res.status(405).json({ error: 'Method not allowed' });
return json({ error: 'Method not allowed' }, { status: 405 });
}
37 changes: 21 additions & 16 deletions handlers/critique.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// AI Review Critic — grades the learner's recall/explanation answer against a
// reference answer. BYOK only (no server-key fallback, so no auth needed).
import { readJsonBody } from '../shared/api/read-json.mjs';
import { generate, parseJSON } from '../shared/lib/ai.mjs';

const SYSTEM = `You grade an engineer's recall answer against a reference answer.
Expand Down Expand Up @@ -71,15 +72,17 @@ export function validateSystemDesignResponse(value, systemDesignCase, stageAnswe
return { dimensions: value.dimensions, verdict: value.verdict.trim() };
}

export default async function handler(req, res) {
if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' });
export default async function handler({ request, json }) {
if (request.method !== 'POST') return json({ error: 'Method not allowed' }, { status: 405 });

const { aiConfig, question, answer, expected, systemDesignCase, stageAnswers } = req.body || {};
const { aiConfig, question, answer, expected, systemDesignCase, stageAnswers } =
await readJsonBody(request);
const hasAI = aiConfig?.endpointUrl && aiConfig.apiKey && aiConfig.model;
if (!hasAI) {
return res
.status(400)
.json({ error: 'Configure an AI provider in Settings to use the Review Critic.' });
return json(
{ error: 'Configure an AI provider in Settings to use the Review Critic.' },
{ status: 400 }
);
}
if (systemDesignCase) {
if (
Expand All @@ -89,9 +92,10 @@ export default async function handler(req, res) {
!stageAnswers ||
typeof stageAnswers !== 'object'
) {
return res
.status(400)
.json({ error: 'valid systemDesignCase and stageAnswers are required' });
return json(
{ error: 'valid systemDesignCase and stageAnswers are required' },
{ status: 400 }
);
}

const systemDesignPrompt = `Case and fixed rubric:\n${JSON.stringify(systemDesignCase)}\n\nLearner stage answers:\n${JSON.stringify(stageAnswers)}\n\nGrade now. JSON only.`;
Expand All @@ -110,16 +114,17 @@ export default async function handler(req, res) {
stageAnswers
);
if (!validated) throw new Error('provider returned an invalid system-design critique');
return res.status(200).json(validated);
return json(validated);
} catch (err) {
return res
.status(502)
.json({ error: `AI request failed: ${err.message || 'unknown error'}` });
return json(
{ error: `AI request failed: ${err.message || 'unknown error'}` },
{ status: 502 }
);
}
}

if (!question || !answer) {
return res.status(400).json({ error: 'question and answer are required' });
return json({ error: 'question and answer are required' }, { status: 400 });
}

const prompt = `Question:
Expand All @@ -142,8 +147,8 @@ Grade now. JSON only.`;
prompt,
maxTokens: 800,
});
return res.status(200).json(parseJSON(text));
return json(parseJSON(text));
} catch (err) {
return res.status(502).json({ error: `AI request failed: ${err.message || 'unknown error'}` });
return json({ error: `AI request failed: ${err.message || 'unknown error'}` }, { status: 502 });
}
}
Loading
Loading