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 });
}),
},
};
}
@@ -0,0 +1,65 @@
import { expect } from "bun:test";
import { forEachDb } from "./helpers";
import { FIXTURES, makeApp } from "./app";
import { extraCourses } from "./courses";
forEachDb("progress: mark done, positions never undo done, gating blocks writes", async (db) => {
const t = await makeApp(db, [FIXTURES, await extraCourses()]);
await t.signUp("[email protected]");
const me = await t.signUp("[email protected]");
const put = (course: string, lesson: string, body: unknown, cookie = me) => t.call("PUT", `/api/courses/${course}/progress/${lesson}`, { cookie, body });
expect((await put("sample", "m1-intro", { state: "done" }, "")).status).toBe(401);
expect((await put("sample", "m1-intro", { state: "finished" })).status).toBe(400);
expect((await (await put("sample", "m1-video", { state: "in-progress", positionSeconds: 1.5 })).json()).positionSeconds).toBe(1.5);
await put("sample", "m1-video", { state: "done" });
const after = await (await put("sample", "m1-video", { state: "in-progress", positionSeconds: 0.5 })).json();
expect(after).toMatchObject({ state: "done", positionSeconds: 0.5 });
expect((await put("sample", "nope", { state: "done" })).status).toBe(404);
expect((await put("gated", "g2a", { state: "done" })).status).toBe(403);
await put("gated", "g1a", { state: "done" });
await put("gated", "g1b", { state: "done" });
expect((await put("gated", "g2a", { state: "done" })).status).toBe(200);
const all = await (await t.call("GET", "/api/courses/gated/progress", { cookie: me })).json();
expect(Object.keys(all).sort()).toEqual(["g1a", "g1b", "g2a"]);
}, 30_000);
forEachDb("notes persist per lesson with slide and video anchors", async (db) => {
const t = await makeApp(db);
const me = await t.signUp("[email protected]");
const other = await t.signUp("[email protected]");
const post = (b: unknown, cookie = me) => t.call("POST", "/api/courses/sample/notes", { cookie, body: b });
expect((await post({ lessonId: "m1-video", anchorKind: "slide", anchorValue: 2, body: "x" })).status).toBe(400); // no deck id
expect((await post({ lessonId: "m1-video", anchorKind: "video", body: "x" })).status).toBe(400);
const s10 = await (await post({ lessonId: "m1-video", anchorKind: "slide", anchorValue: 10, deckId: "d1", body: "slide ten" })).json();
await post({ lessonId: "m1-video", anchorKind: "slide", anchorValue: 2, deckId: "d1", body: "slide two" });
await post({ lessonId: "m1-video", anchorKind: "video", anchorValue: 1.25, body: "at 1.25s" });
await post({ lessonId: "m1-intro", body: "general" });
const list = await (await t.call("GET", "/api/courses/sample/notes?lesson=m1-video", { cookie: me })).json();
expect(list.map((n: { body: string }) => n.body)).toEqual(["slide two", "slide ten", "at 1.25s"]);
expect(list[0]).toMatchObject({ anchorKind: "slide", anchorValue: "2", deckId: "d1" });
expect(list[0].userId).toBeUndefined();
expect((await (await t.call("GET", "/api/courses/sample/notes", { cookie: me })).json()).length).toBe(4);
expect(await (await t.call("GET", "/api/courses/sample/notes", { cookie: other })).json()).toEqual([]);
expect((await t.call("PATCH", `/api/notes/${s10.id}`, { cookie: other, body: { body: "hijack" } })).status).toBe(404);
expect((await (await t.call("PATCH", `/api/notes/${s10.id}`, { cookie: me, body: { body: "edited" } })).json()).body).toBe("edited");
expect((await t.call("DELETE", `/api/notes/${s10.id}`, { cookie: other })).status).toBe(404);
expect((await t.call("DELETE", `/api/notes/${s10.id}`, { cookie: me })).status).toBe(200);
expect((await (await t.call("GET", "/api/courses/sample/notes?lesson=m1-video", { cookie: me })).json()).length).toBe(2);
}, 30_000);
forEachDb("exercise attempts save code and a pass marks the lesson done", async (db) => {
const t = await makeApp(db);
const me = await t.signUp("[email protected]");
const url = "/api/courses/sample/lessons/m2-ex-add/attempt";
expect(await (await t.call("GET", `${url}?track=js`, { cookie: me })).json()).toBeNull();
const r1 = await (await t.call("PUT", url, { cookie: me, body: { track: "js", code: "draft", passed: false, result: [{ name: "a", passed: false }] } })).json();
expect(r1).toEqual({ ok: true, passed: false, state: "in-progress" });
const r2 = await (await t.call("PUT", url, { cookie: me, body: { track: "js", code: "export const add=(a,b)=>a+b", passed: true, result: [] } })).json();
expect(r2.state).toBe("done");
const got = await (await t.call("GET", `${url}?track=js`, { cookie: me })).json();
expect(got).toMatchObject({ code: "export const add=(a,b)=>a+b", passed: true });
expect((await t.call("PUT", "/api/courses/sample/lessons/m1-intro/attempt", { cookie: me, body: { code: "x" } })).status).toBe(400);
}, 30_000);