feat(learner): library, search, course outline with gating, lesson payload API

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 03:23:30 +01:00
co-authored by Claude Opus 5.5
parent a8a8e32bcb
commit 5f85ce1d4b
10 changed files with 699 additions and 1 deletions
@@ -0,0 +1,44 @@
import type { AuthUser, ServerContext } from "@stc/contracts";
import { canView } from "@stc/core";
import type { LoadedCourse } from "@stc/course-format";
import type { Db } from "@stc/db";
export interface AccessIndex {
acl: Map<string, { principal_type: string; principal_id: string }[]>;
authorOf: Set<string>;
}
/** All ACL rows plus the courses the user co-authors, fetched once per request. */
export async function accessIndex(db: Db, user: AuthUser | null): Promise<AccessIndex> {
const acl = new Map<string, { principal_type: string; principal_id: string }[]>();
const authorOf = new Set<string>();
if (!user) return { acl, authorOf };
for (const r of await db.sql`SELECT course_id, principal_type, principal_id FROM course_acl`) {
const list = acl.get(r.course_id) ?? [];
list.push({ principal_type: r.principal_type, principal_id: r.principal_id });
acl.set(r.course_id, list);
}
for (const r of await db.sql`SELECT course_id FROM course_authors WHERE user_id = ${user.id}`) authorOf.add(r.course_id);
return { acl, authorOf };
}
export const isOwner = (u: AuthUser | null): boolean => !!u?.roles.includes("owner");
export function canEdit(user: AuthUser | null, courseId: string, idx: AccessIndex): boolean {
return !!user && (user.roles.includes("owner") || user.roles.includes("author") || idx.authorOf.has(courseId));
}
/** Learners see courses with a manifest that `canView` allows; owners also see broken ones. */
export function visible(user: AuthUser | null, c: LoadedCourse, idx: AccessIndex): boolean {
if (!c.manifest) return isOwner(user);
return canView(user, c, idx.acl.get(c.id) ?? [], canEdit(user, c.id, idx));
}
/** The course if this user may read it, else null (callers answer 404, never 403, so hidden courses stay hidden). */
export async function courseFor(ctx: ServerContext, user: AuthUser | null, courseId: string) {
const course = ctx.courses.get(courseId);
if (!course || !course.manifest) return null;
const idx = await accessIndex(ctx.db, user);
if (!visible(user, course, idx)) return null;
return { course, manifest: course.manifest, idx, canEdit: canEdit(user, courseId, idx) };
}
@@ -0,0 +1,216 @@
import { statSync } from "node:fs";
import type { AuthUser, Feature, ServerContext } from "@stc/contracts";
import type { LoadedCourse, Manifest } from "@stc/course-format";
import { libraryRepo, progressRepo } from "@stc/db";
import type {
ContinueItem, CourseCard, CoursePayload, Format, LessonProgress, LibraryPayload, OutlineModule, SearchHit,
} from "../shared/types";
import { mediaUrl } from "../shared/types";
import { accessIndex, courseFor, isOwner, visible } from "./access";
import { moduleLocks } from "./gating";
import { json, notFound, param } from "./http";
const WPM = 220;
const EXERCISE_MINUTES = 15;
/** Lesson length estimates (minutes), cached per course and manifest mtime. */
export function createEstimator(ctx: ServerContext) {
const cache = new Map<string, { key: string; minutes: Map<string, number | null> }>();
return async function minutesFor(c: LoadedCourse): Promise<Map<string, number | null>> {
let key = c.manifestPath;
try { key += `@${statSync(c.manifestPath).mtimeMs}`; } catch { /* keep path */ }
const hit = cache.get(c.id);
if (hit && hit.key === key) return hit.minutes;
const minutes = new Map<string, number | null>();
for (const mod of c.manifest?.modules ?? []) {
for (const l of mod.lessons) {
if (l.type === "video") {
minutes.set(l.id, l.duration ? l.duration / 60 : null);
continue;
}
let words = 0;
try {
const md = await ctx.courses.readLessonMarkdown(c.id, l.id);
words = md ? md.split(/\s+/).filter(Boolean).length : 0;
} catch { /* unreadable: no estimate */ }
const read = words / WPM;
minutes.set(l.id, l.type === "exercise" ? Math.max(EXERCISE_MINUTES, read) : words ? Math.max(1, read) : null);
}
}
cache.set(c.id, { key, minutes });
return minutes;
};
}
export function formatsOf(m: Manifest): Format[] {
const types = new Set(m.modules.flatMap((x) => x.lessons.map((l) => l.type)));
const f: Format[] = [];
if (types.has("text")) f.push("text");
if (types.has("video")) f.push("video");
if (m.decks?.length) f.push("slides");
if (types.has("exercise")) f.push("exercises");
return f;
}
export function addedAt(c: LoadedCourse): string {
const imported = c.manifest?.source?.importedAt;
if (imported && !Number.isNaN(Date.parse(imported))) return new Date(imported).toISOString();
try { return statSync(c.manifestPath).mtime.toISOString(); } catch { return new Date(0).toISOString(); }
}
const slug = (s: string) => s.trim().toLowerCase().normalize("NFKD").replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-|-$/g, "");
const fold = (s: string) => s.normalize("NFKD").replace(/\p{M}/gu, "").toLowerCase();
type ProgressByCourse = Map<string, Record<string, LessonProgress>>;
async function allProgress(ctx: ServerContext, user: AuthUser | null): Promise<ProgressByCourse> {
const out: ProgressByCourse = new Map();
if (!user) return out;
const rows = await ctx.db.sql`SELECT course_id, lesson_id, state, position_seconds, updated_at FROM progress WHERE user_id = ${user.id}`;
for (const r of rows) {
const rec = out.get(r.course_id) ?? {};
rec[r.lesson_id] = { state: r.state, positionSeconds: r.position_seconds === null ? null : Number(r.position_seconds), updatedAt: r.updated_at };
out.set(r.course_id, rec);
}
return out;
}
export function libraryRoutes(ctx: ServerContext): Feature["routes"] {
const minutesFor = createEstimator(ctx);
const lib = libraryRepo(ctx.db);
async function card(c: LoadedCourse, progress: Record<string, LessonProgress>): Promise<CourseCard> {
const m = c.manifest;
if (!m) {
return {
id: c.id, title: c.id, pitch: c.diagnostics[0]?.message ?? null, authors: [], coverUrl: null, accent: null, formats: [],
lessonCount: 0, minutes: 0, collections: [], tracks: [], source: null, addedAt: addedAt(c), status: "error", progress: 0, doneCount: 0, started: false,
};
}
const lessons = m.modules.flatMap((x) => x.lessons);
const est = await minutesFor(c);
const minutes = Math.round(lessons.reduce((s, l) => s + (est.get(l.id) ?? 0), 0));
const doneCount = lessons.filter((l) => progress[l.id]?.state === "done").length;
return {
id: m.id, title: m.title, pitch: m.pitch ?? null, authors: m.authors ?? [],
coverUrl: m.cover && ctx.courses.resolvePath(m.id, m.cover) ? mediaUrl(m.id, m.cover) : null,
accent: m.accent ?? null, formats: formatsOf(m), lessonCount: lessons.length, minutes,
collections: m.collections ?? [], tracks: (m.tracks ?? []).map((t) => t.label), source: m.source?.kind ?? null,
addedAt: addedAt(c), status: c.status, progress: lessons.length ? doneCount / lessons.length : 0, doneCount,
started: lessons.some((l) => progress[l.id]),
};
}
function continueItem(c: LoadedCourse, progress: Record<string, LessonProgress>, bypass: boolean): ContinueItem | null {
const m = c.manifest!;
const lessons = m.modules.flatMap((x) => x.lessons);
const done = lessons.filter((l) => progress[l.id]?.state === "done").length;
if (!lessons.some((l) => progress[l.id]) || done === lessons.length) return null;
const locks = moduleLocks(m, progress, bypass);
for (const [i, mod] of m.modules.entries()) {
if (locks[i]) break;
const next = mod.lessons.find((l) => progress[l.id]?.state !== "done");
if (!next) continue;
return {
courseId: m.id, lessonId: next.id, lessonTitle: next.title, moduleTitle: mod.title, moduleIndex: i,
moduleDone: mod.lessons.filter((l) => progress[l.id]?.state === "done").length, moduleTotal: mod.lessons.length,
progress: done / lessons.length,
};
}
return null;
}
return {
"/api/library": {
GET: async (_req, rc) => {
const user = rc.user;
const idx = await accessIndex(ctx.db, user);
const courses = ctx.courses.list().filter((c) => visible(user, c, idx));
const progress = await allProgress(ctx, user);
const cards = await Promise.all(courses.map((c) => card(c, progress.get(c.id) ?? {})));
const byId = new Map(cards.map((c) => [c.id, c]));
const cont: (ContinueItem & { at: string })[] = [];
for (const c of courses) {
if (!c.manifest) continue;
const p = progress.get(c.id) ?? {};
const item = continueItem(c, p, isOwner(user));
if (item) cont.push({ ...item, at: Object.values(p).map((x) => x.updatedAt).sort().pop() ?? "" });
}
cont.sort((a, b) => (a.at < b.at ? 1 : -1));
const newest = [...cards].filter((c) => c.status === "ok" || c.lessonCount).sort((a, b) => (a.addedAt < b.addedAt ? 1 : a.addedAt > b.addedAt ? -1 : a.title.localeCompare(b.title))).map((c) => c.id);
const collections: LibraryPayload["collections"] = [];
for (const col of await lib.listCollections()) {
collections.push({ id: col.id, title: col.title, courseIds: col.courseIds.filter((id) => byId.has(id)) });
}
for (const c of cards) {
for (const name of c.collections) {
const id = slug(name);
let col = collections.find((x) => x.id === id || x.title.toLowerCase() === name.toLowerCase());
if (!col) collections.push((col = { id, title: name, courseIds: [] }));
if (!col.courseIds.includes(c.id)) col.courseIds.push(c.id);
}
}
const featuredId = await lib.getFeatured();
const featured = (featuredId && byId.get(featuredId)) || (cont[0] && byId.get(cont[0].courseId)) || byId.get(newest[0] ?? "") || null;
const payload: LibraryPayload = {
signedIn: !!user,
featured,
continue: cont.map(({ at: _at, ...rest }) => rest),
newest,
collections: collections.filter((c) => c.courseIds.length > 0),
courses: cards.sort((a, b) => a.title.localeCompare(b.title)),
};
return json(payload);
},
},
"/api/library/search": {
GET: async (_req, rc) => {
const q = fold(rc.url.searchParams.get("q") ?? "").trim();
if (!q) return json([]);
const terms = q.split(/\s+/);
const hit = (s: string | undefined) => !!s && terms.every((t) => fold(s).includes(t));
const idx = await accessIndex(ctx.db, rc.user);
const out: SearchHit[] = [];
for (const c of ctx.courses.list()) {
const m = c.manifest;
if (!m || !visible(rc.user, c, idx)) continue;
const course = hit(`${m.title} ${m.pitch ?? ""} ${(m.authors ?? []).join(" ")} ${(m.collections ?? []).join(" ")}`);
const lessons = m.modules.flatMap((mod) => mod.lessons.filter((l) => hit(l.title) || hit(`${mod.title} ${l.title}`)).map((l) => ({ id: l.id, title: l.title, moduleTitle: mod.title })));
if (course || lessons.length) out.push({ courseId: m.id, courseTitle: m.title, course, lessons: lessons.slice(0, 8) });
}
out.sort((a, b) => Number(b.course) - Number(a.course) || b.lessons.length - a.lessons.length);
return json(out.slice(0, 50));
},
},
"/api/courses/:courseId": {
GET: async (_req, rc) => {
const found = await courseFor(ctx, rc.user, param(rc, "courseId"));
if (!found) return notFound();
const { course, manifest: m } = found;
const progress = rc.user ? await progressRepo(ctx.db).get(rc.user.id, m.id) : {};
const est = await minutesFor(course);
const locks = moduleLocks(m, progress, found.canEdit);
const deckTargets = new Set((m.decks ?? []).map((d) => d.attachTo));
const modules: OutlineModule[] = m.modules.map((mod, i) => ({
id: mod.id, title: mod.title, index: i, locked: locks[i]!, hasCode: !!mod.code,
lessons: mod.lessons.map((l, li) => ({
id: l.id, title: l.title, type: l.type, minutes: est.get(l.id) ?? null,
hasDeck: deckTargets.has(l.id) || (li === 0 && deckTargets.has(mod.id)), browser: !!l.browser,
})),
}));
const payload: CoursePayload = {
id: m.id, title: m.title, pitch: m.pitch ?? null, authors: m.authors ?? [], accent: m.accent ?? null,
coverUrl: m.cover && ctx.courses.resolvePath(m.id, m.cover) ? mediaUrl(m.id, m.cover) : null,
tracks: m.tracks ?? [], gating: m.gating, source: m.source?.kind ?? null, status: course.status,
modules, progress, canEdit: found.canEdit, signedIn: !!rc.user,
diagnostics: found.canEdit ? course.diagnostics.map((d) => ({ level: d.level, lessonId: d.lessonId, path: d.path, message: d.message })) : [],
};
return json(payload);
},
},
};
}
+11 -1
View File
@@ -1,8 +1,10 @@
import type { AuthService, Feature, ServerContext } from "@stc/contracts";
import type { AuthService, Feature, RouteHandler, ServerContext } from "@stc/contracts";
import { createAuth } from "./auth";
import { runMigrations } from "./migrate";
import { json } from "./http";
import { adminRoutes } from "./admin";
import { libraryRoutes } from "./catalog";
import { lessonRoutes } from "./lesson";
/**
* The learner feature: auth, library, reader, notes, progress and exercises.
@@ -22,6 +24,14 @@ export function learnerFeature(ctx: ServerContext): Feature & { auth: AuthServic
GET: async () => json({ emailPassword: true, providers: auth.providers(), sso: await auth.hasSso() }),
},
...adminRoutes(ctx, auth.service.invalidate),
...libraryRoutes(ctx),
...lessonRoutes(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)) {
for (const [m, h] of Object.entries(methods)) {
(methods as Record<string, RouteHandler>)[m] = async (req, rc) => { await ready; return h!(req, rc); };
}
}
return { name: "learner", routes, auth: auth.service };
}
@@ -0,0 +1,15 @@
import type { Manifest } from "@stc/course-format";
/**
* Sequential gating (spec section 12): with `gating: sequential`, module i is locked until every
* lesson of every earlier module is done. `bypass` (owners and course authors) unlocks everything.
*/
export function moduleLocks(m: Manifest, progress: Record<string, { state: string }>, bypass = false): boolean[] {
if (m.gating !== "sequential" || bypass) return m.modules.map(() => false);
let open = true;
return m.modules.map((mod) => {
const locked = !open;
if (!mod.lessons.every((l) => progress[l.id]?.state === "done")) open = false;
return locked;
});
}
@@ -0,0 +1,115 @@
import { statSync } from "node:fs";
import type { Feature, ServerContext } from "@stc/contracts";
import { extractHints, extractSection, type Lesson, type Manifest } from "@stc/course-format";
import { progressRepo } from "@stc/db";
import type { FileRef, LessonPayload } from "../shared/types";
import { mediaUrl } from "../shared/types";
import { courseFor } from "./access";
import { createEstimator } from "./catalog";
import { moduleLocks } from "./gating";
import { json, notFound, param } from "./http";
const isMarkdown = (p: string) => /\.(md|markdown)$/i.test(p);
export function lessonFiles(m: Manifest): Record<string, string> {
const out: Record<string, string> = {};
for (const mod of m.modules) for (const l of mod.lessons) if (l.file && !l.anchor && isMarkdown(l.file) && !(l.file in out)) out[l.file] = l.id;
return out;
}
export function lessonRoutes(ctx: ServerContext): Feature["routes"] {
const minutesFor = createEstimator(ctx);
const read = async (courseId: string, rel: string): Promise<string | null> => {
const abs = ctx.courses.resolvePath(courseId, rel);
if (!abs) return null;
try { return await Bun.file(abs).text(); } catch { return null; }
};
const fileRef = (courseId: string, file: string, title?: string): FileRef => {
const abs = ctx.courses.resolvePath(courseId, file);
let size: number | null = null;
try { if (abs) size = statSync(abs).size; } catch { /* missing */ }
return { title: title ?? file.split("/").pop()!, file, url: mediaUrl(courseId, file), size };
};
return {
"/api/courses/:courseId/lessons/:lessonId": {
GET: async (_req, rc) => {
const found = await courseFor(ctx, rc.user, param(rc, "courseId"));
if (!found) return notFound();
const { course, manifest: m } = found;
const lessonId = param(rc, "lessonId");
const entry = course.lessonIndex.get(lessonId);
if (!entry) return notFound();
const { lesson: l } = entry;
const modIndex = m.modules.findIndex((x) => x.id === entry.moduleId);
const mod = m.modules[modIndex]!;
const flat: { l: Lesson; mi: number }[] = m.modules.flatMap((x, mi) => x.lessons.map((ll) => ({ l: ll, mi })));
const pos = flat.findIndex((x) => x.l.id === l.id);
const progress = rc.user ? await progressRepo(ctx.db).get(rc.user.id, m.id) : {};
const locks = moduleLocks(m, progress, found.canEdit);
const link = (x: { l: Lesson; mi: number } | undefined) => (x ? { id: x.l.id, title: x.l.title } : null);
const est = await minutesFor(course);
const base: LessonPayload = {
courseId: m.id, courseTitle: m.title,
lesson: { id: l.id, title: l.title, type: l.type, minutes: est.get(l.id) ?? null },
module: { id: mod.id, title: mod.title, index: modIndex },
locked: locks[modIndex]!,
markdown: null, baseFile: null, trackMarkdown: {}, tracks: m.tracks ?? [],
video: null, transcript: null, hints: [], decks: [], code: null, attachments: [], browser: null,
localCommand: null,
prev: link(flat[pos - 1]), next: link(flat[pos + 1]),
lessonFiles: lessonFiles(m),
};
if (base.locked) return json(base);
if (l.type === "video") {
if (l.file) base.video = { url: mediaUrl(m.id, l.file), file: l.file, duration: l.duration ?? null };
if (l.transcript && isMarkdown(l.transcript)) {
base.transcript = await read(m.id, l.transcript);
base.baseFile = l.transcript;
}
} else if (l.file && isMarkdown(l.file)) {
const text = await read(m.id, l.file);
base.markdown = text === null ? null : l.anchor ? extractSection(text, l.anchor) : text;
base.baseFile = l.file;
}
for (const [track, t] of Object.entries(l.tracks ?? {})) {
if (t.file && isMarkdown(t.file)) {
const text = await read(m.id, t.file);
if (text !== null) base.trackMarkdown[track] = l.anchor ? extractSection(text, l.anchor) ?? text : text;
}
}
if (l.hints) {
const text = await read(m.id, l.hints.file);
if (text !== null) base.hints = extractHints(text, l.hints.match);
}
const first = mod.lessons[0]?.id === l.id;
base.decks = (m.decks ?? [])
.filter((d) => d.attachTo === l.id || (first && d.attachTo === mod.id))
.filter((d) => ctx.courses.resolvePath(m.id, d.file))
.map((d) => ({ id: d.id, title: d.title ?? d.file.split("/").pop()!.replace(/\.pdf$/i, ""), url: mediaUrl(m.id, d.file), attachTo: d.attachTo }));
if (mod.code) base.code = { moduleId: mod.id, dir: mod.code.dir, command: mod.code.command ?? null };
base.attachments = (m.attachments ?? [])
.filter((a) => !a.module || a.module === mod.id)
.filter((a) => ctx.courses.resolvePath(m.id, a.file))
.map((a) => fileRef(m.id, a.file, a.title));
if (l.browser) {
const starter = await read(m.id, l.browser.starter);
const tests = await read(m.id, l.browser.tests);
if (starter !== null && tests !== null) {
base.browser = {
language: l.browser.language, starter, tests, starterFile: l.browser.starter.split("/").pop()!,
// the solution is visible to authors only (spec section 5)
solution: found.canEdit && l.browser.solution ? await read(m.id, l.browser.solution) : null,
};
}
}
const trackCommands = Object.fromEntries(Object.entries(l.tracks ?? {}).filter(([, t]) => t.command).map(([k, t]) => [k, t.command!]));
base.localCommand = l.localCommand ?? (Object.keys(trackCommands).length ? trackCommands : null);
return json(base);
},
},
};
}