diff --git a/packages/feature-learner/src/server/feature.ts b/packages/feature-learner/src/server/feature.ts index c01bf11..0a857c0 100644 --- a/packages/feature-learner/src/server/feature.ts +++ b/packages/feature-learner/src/server/feature.ts @@ -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)) { diff --git a/packages/feature-learner/src/server/userdata.ts b/packages/feature-learner/src/server/userdata.ts new file mode 100644 index 0000000..050b243 --- /dev/null +++ b/packages/feature-learner/src/server/userdata.ts @@ -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[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 }); + }), + }, + }; +} diff --git a/packages/feature-learner/test/userdata.test.ts b/packages/feature-learner/test/userdata.test.ts new file mode 100644 index 0000000..2148b64 --- /dev/null +++ b/packages/feature-learner/test/userdata.test.ts @@ -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("owner@example.com"); + const me = await t.signUp("l@example.com"); + 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("l@example.com"); + const other = await t.signUp("o@example.com"); + 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("l@example.com"); + 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);