feat(learner): progress, notes and exercise attempt API

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 03:24:16 +01:00
co-authored by Claude Opus 5.5
parent 5f85ce1d4b
commit 96c147e3a2
3 changed files with 184 additions and 0 deletions
@@ -5,6 +5,7 @@ import { json } from "./http";
import { adminRoutes } from "./admin";
import { libraryRoutes } from "./catalog";
import { lessonRoutes } from "./lesson";
import { userDataRoutes } from "./userdata";
/**
* The learner feature: auth, library, reader, notes, progress and exercises.
@@ -26,6 +27,7 @@ export function learnerFeature(ctx: ServerContext): Feature & { auth: AuthServic
...adminRoutes(ctx, auth.service.invalidate),
...libraryRoutes(ctx),
...lessonRoutes(ctx),
...userDataRoutes(ctx),
};
// Every handler waits for the learner migrations (a no-op once Phase 2 runs them in migrate()).
for (const methods of Object.values(routes)) {
@@ -0,0 +1,117 @@
import type { Feature, ServerContext } from "@stc/contracts";
import { attemptsRepo, notesRepo, progressRepo, type Note as DbNote } from "@stc/db";
import type { Note, ProgressState } from "../shared/types";
import { courseFor } from "./access";
import { moduleLocks } from "./gating";
import { badRequest, body, json, notFound, param, signedIn } from "./http";
const STATES: ProgressState[] = ["not-started", "in-progress", "done"];
const KINDS = ["none", "video", "slide"] as const;
const MAX_NOTE = 20_000;
const MAX_CODE = 200_000;
const publicNote = ({ userId: _u, ...n }: DbNote): Note => n;
/** Progress, notes and exercise attempts: per user, signed-in only, scoped to courses the user can see. */
export function userDataRoutes(ctx: ServerContext): Feature["routes"] {
const progress = progressRepo(ctx.db);
const notes = notesRepo(ctx.db);
const attempts = attemptsRepo(ctx.db);
/** Course and lesson the user may write to, or an error response. */
async function target(userId: string, user: Parameters<typeof courseFor>[1], courseId: string, lessonId: string) {
const found = await courseFor(ctx, user, courseId);
if (!found) return { error: notFound() };
const entry = found.course.lessonIndex.get(lessonId);
if (!entry) return { error: notFound() };
const p = await progress.get(userId, courseId);
const mi = found.manifest.modules.findIndex((m) => m.id === entry.moduleId);
if (moduleLocks(found.manifest, p, found.canEdit)[mi]) return { error: json({ error: "this module is locked until the previous one is done" }, 403) };
return { found, entry, progress: p };
}
return {
"/api/courses/:courseId/progress": {
GET: signedIn(async (_req, rc, user) => {
const courseId = param(rc, "courseId");
if (!(await courseFor(ctx, user, courseId))) return notFound();
return json(await progress.get(user.id, courseId));
}),
},
"/api/courses/:courseId/progress/:lessonId": {
PUT: signedIn(async (req, rc, user) => {
const courseId = param(rc, "courseId"), lessonId = param(rc, "lessonId");
const b = await body<{ state?: ProgressState; positionSeconds?: number }>(req);
if (!b || !STATES.includes(b.state as ProgressState)) return badRequest("state must be not-started, in-progress or done");
if (b.positionSeconds !== undefined && (typeof b.positionSeconds !== "number" || !Number.isFinite(b.positionSeconds) || b.positionSeconds < 0)) return badRequest("positionSeconds must be a non-negative number");
const t = await target(user.id, user, courseId, lessonId);
if ("error" in t) return t.error;
const prior = t.progress[lessonId];
// A position report ("in-progress") never undoes "done"; only an explicit not-started does.
const state = prior?.state === "done" && b.state === "in-progress" ? "done" : b.state!;
const pos = b.positionSeconds ?? prior?.positionSeconds ?? undefined;
await progress.set(user.id, courseId, lessonId, state, pos ?? undefined);
return json((await progress.get(user.id, courseId))[lessonId]);
}),
},
"/api/courses/:courseId/notes": {
GET: signedIn(async (_req, rc, user) => {
const courseId = param(rc, "courseId");
if (!(await courseFor(ctx, user, courseId))) return notFound();
const lesson = rc.url.searchParams.get("lesson") ?? undefined;
return json((await notes.list(user.id, courseId, lesson)).map(publicNote));
}),
POST: signedIn(async (req, rc, user) => {
const courseId = param(rc, "courseId");
const b = await body<{ lessonId?: string; anchorKind?: string; anchorValue?: string | number | null; deckId?: string | null; body?: string }>(req);
if (!b || typeof b.lessonId !== "string" || typeof b.body !== "string") return badRequest("lessonId and body are required");
if (b.body.length > MAX_NOTE) return badRequest("note is too long");
const kind = (b.anchorKind ?? "none") as (typeof KINDS)[number];
if (!KINDS.includes(kind)) return badRequest("anchorKind must be none, video or slide");
if (kind !== "none" && (b.anchorValue === null || b.anchorValue === undefined || Number.isNaN(Number(b.anchorValue)))) return badRequest("video and slide notes need a numeric anchorValue");
const found = await courseFor(ctx, user, courseId);
if (!found || !found.course.lessonIndex.has(b.lessonId)) return notFound();
if (kind === "slide" && !(found.manifest.decks ?? []).some((d) => d.id === b.deckId)) return badRequest("slide notes need the deckId of a deck in this course");
const n = await notes.create({
userId: user.id, courseId, lessonId: b.lessonId, anchorKind: kind,
anchorValue: kind === "none" ? null : String(b.anchorValue), deckId: kind === "slide" ? b.deckId! : null, body: b.body,
});
return json(publicNote(n), 201);
}),
},
"/api/notes/:id": {
PATCH: signedIn(async (req, rc, user) => {
const b = await body<{ body?: string }>(req);
if (!b || typeof b.body !== "string") return badRequest("body is required");
if (b.body.length > MAX_NOTE) return badRequest("note is too long");
const n = await notes.update(param(rc, "id"), user.id, b.body);
return n ? json(publicNote(n)) : notFound();
}),
DELETE: signedIn(async (_req, rc, user) => ((await notes.remove(param(rc, "id"), user.id)) ? json({ ok: true }) : notFound())),
},
"/api/courses/:courseId/lessons/:lessonId/attempt": {
GET: signedIn(async (_req, rc, user) => {
const courseId = param(rc, "courseId"), lessonId = param(rc, "lessonId");
const found = await courseFor(ctx, user, courseId);
if (!found || !found.course.lessonIndex.has(lessonId)) return notFound();
const a = await attempts.get(user.id, courseId, lessonId, rc.url.searchParams.get("track") ?? "default");
return json(a ? { code: a.code, result: a.result, passed: a.passed, updatedAt: a.updatedAt } : null);
}),
PUT: signedIn(async (req, rc, user) => {
const courseId = param(rc, "courseId"), lessonId = param(rc, "lessonId");
const b = await body<{ track?: string; code?: string; result?: unknown; passed?: boolean }>(req);
if (!b || typeof b.code !== "string") return badRequest("code is required");
if (b.code.length > MAX_CODE) return badRequest("code is too long");
const t = await target(user.id, user, courseId, lessonId);
if ("error" in t) return t.error;
if (t.entry.lesson.type !== "exercise") return badRequest("not an exercise");
const passed = b.passed === true;
await attempts.save({ userId: user.id, courseId, lessonId, track: b.track || "default", code: b.code, result: b.result ?? null, passed, updatedAt: new Date().toISOString() });
// An exercise counts as done when all its tests pass (spec 4.4).
if (passed) await progress.set(user.id, courseId, lessonId, "done");
else if (!t.progress[lessonId]) await progress.set(user.id, courseId, lessonId, "in-progress");
return json({ ok: true, passed, state: (await progress.get(user.id, courseId))[lessonId]?.state });
}),
},
};
}