feat(learner): progress, notes and exercise attempt API
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5f85ce1d4b
commit
96c147e3a2
@@ -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);
|
||||
Reference in New Issue
Block a user