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 });
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user