diff --git a/packages/feature-learner/src/server/access.ts b/packages/feature-learner/src/server/access.ts new file mode 100644 index 0000000..9ad4ea2 --- /dev/null +++ b/packages/feature-learner/src/server/access.ts @@ -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; + authorOf: Set; +} + +/** All ACL rows plus the courses the user co-authors, fetched once per request. */ +export async function accessIndex(db: Db, user: AuthUser | null): Promise { + const acl = new Map(); + const authorOf = new Set(); + 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) }; +} diff --git a/packages/feature-learner/src/server/catalog.ts b/packages/feature-learner/src/server/catalog.ts new file mode 100644 index 0000000..e2395b7 --- /dev/null +++ b/packages/feature-learner/src/server/catalog.ts @@ -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 }>(); + return async function minutesFor(c: LoadedCourse): Promise> { + 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(); + 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>; + +async function allProgress(ctx: ServerContext, user: AuthUser | null): Promise { + 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): Promise { + 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, 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); + }, + }, + }; +} diff --git a/packages/feature-learner/src/server/feature.ts b/packages/feature-learner/src/server/feature.ts index 954e50c..c01bf11 100644 --- a/packages/feature-learner/src/server/feature.ts +++ b/packages/feature-learner/src/server/feature.ts @@ -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)[m] = async (req, rc) => { await ready; return h!(req, rc); }; + } + } return { name: "learner", routes, auth: auth.service }; } diff --git a/packages/feature-learner/src/server/gating.ts b/packages/feature-learner/src/server/gating.ts new file mode 100644 index 0000000..ff4de01 --- /dev/null +++ b/packages/feature-learner/src/server/gating.ts @@ -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, 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; + }); +} diff --git a/packages/feature-learner/src/server/lesson.ts b/packages/feature-learner/src/server/lesson.ts new file mode 100644 index 0000000..fb9cafc --- /dev/null +++ b/packages/feature-learner/src/server/lesson.ts @@ -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 { + const out: Record = {}; + 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 => { + 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); + }, + }, + }; +} diff --git a/packages/feature-learner/src/shared/types.ts b/packages/feature-learner/src/shared/types.ts new file mode 100644 index 0000000..9eb4330 --- /dev/null +++ b/packages/feature-learner/src/shared/types.ts @@ -0,0 +1,121 @@ +// JSON shapes shared by the learner API and the web app. + +export type LessonType = "text" | "video" | "exercise"; +export type Format = "text" | "video" | "slides" | "exercises"; +export type ProgressState = "not-started" | "in-progress" | "done"; + +export interface LessonProgress { state: ProgressState; positionSeconds: number | null; updatedAt: string } + +export interface CourseCard { + id: string; + title: string; + pitch: string | null; + authors: string[]; + coverUrl: string | null; + accent: string | null; + formats: Format[]; + lessonCount: number; + minutes: number; + collections: string[]; + tracks: string[]; + source: string | null; + addedAt: string; + status: "ok" | "error"; + /** 0..1 share of lessons done; 0 when signed out. */ + progress: number; + doneCount: number; + started: boolean; +} + +export interface ContinueItem { + courseId: string; + lessonId: string; + lessonTitle: string; + moduleTitle: string; + moduleIndex: number; + moduleDone: number; + moduleTotal: number; + progress: number; +} + +export interface LibraryPayload { + signedIn: boolean; + featured: CourseCard | null; + continue: ContinueItem[]; + newest: string[]; + collections: { id: string; title: string; courseIds: string[] }[]; + courses: CourseCard[]; +} + +export interface SearchHit { courseId: string; courseTitle: string; course: boolean; lessons: { id: string; title: string; moduleTitle: string }[] } + +export interface OutlineLesson { id: string; title: string; type: LessonType; minutes: number | null; hasDeck: boolean; browser: boolean } +export interface OutlineModule { id: string; title: string; index: number; locked: boolean; hasCode: boolean; lessons: OutlineLesson[] } + +export interface Diagnostic { level: "error" | "warning"; lessonId?: string; path?: string; message: string } + +export interface CoursePayload { + id: string; + title: string; + pitch: string | null; + authors: string[]; + accent: string | null; + coverUrl: string | null; + tracks: { id: string; label: string }[]; + gating: "none" | "sequential"; + source: string | null; + status: "ok" | "error"; + modules: OutlineModule[]; + progress: Record; + canEdit: boolean; + signedIn: boolean; + diagnostics: Diagnostic[]; +} + +export interface DeckRef { id: string; title: string; url: string; attachTo: string } +export interface FileRef { title: string; file: string; url: string; size: number | null } +export interface LessonLink { id: string; title: string } + +export interface LessonPayload { + courseId: string; + courseTitle: string; + lesson: { id: string; title: string; type: LessonType; minutes: number | null }; + module: { id: string; title: string; index: number }; + locked: boolean; + /** Lesson body (text: the file or its anchored section; exercise: the anchored section; video: null). */ + markdown: string | null; + /** Course-relative path the markdown came from, for resolving relative links and images. */ + baseFile: string | null; + /** Per-track replacement bodies, when the manifest gives track files. */ + trackMarkdown: Record; + tracks: { id: string; label: string }[]; + video: { url: string; file: string; duration: number | null } | null; + transcript: string | null; + hints: { label: string; body: string }[]; + decks: DeckRef[]; + code: { moduleId: string; dir: string; command: string | null } | null; + attachments: FileRef[]; + browser: { language: "js" | "ts"; starter: string; tests: string; starterFile: string; solution: string | null } | null; + localCommand: string | Record | null; + prev: LessonLink | null; + next: LessonLink | null; + /** Course file → lesson id, for turning links between lesson files into reader links. */ + lessonFiles: Record; +} + +export interface Note { + id: string; + courseId: string; + lessonId: string; + anchorKind: "none" | "video" | "slide"; + anchorValue: string | null; + deckId: string | null; + body: string; + createdAt: string; + updatedAt: string; +} + +export interface CodeListing { moduleId: string; dir: string; command: string | null; files: { path: string; size: number }[]; truncated: boolean } + +export const mediaUrl = (courseId: string, rel: string): string => + `/api/media/${encodeURIComponent(courseId)}/${rel.split("/").map(encodeURIComponent).join("/")}`; diff --git a/packages/feature-learner/test/catalog.test.ts b/packages/feature-learner/test/catalog.test.ts new file mode 100644 index 0000000..62fb789 --- /dev/null +++ b/packages/feature-learner/test/catalog.test.ts @@ -0,0 +1,61 @@ +import { expect } from "bun:test"; +import { forEachDb } from "./helpers"; +import { FIXTURES, makeApp } from "./app"; +import { extraCourses } from "./courses"; + +forEachDb("library shows only visible courses, with formats and lengths", async (db) => { + const t = await makeApp(db, [FIXTURES, await extraCourses()]); + const anon = await (await t.call("GET", "/api/library")).json(); + expect(anon.signedIn).toBe(false); + expect(anon.courses.map((c: { id: string }) => c.id)).toEqual(["sample"]); + const sample = anon.courses[0]; + expect(sample.formats).toEqual(["text", "video", "slides", "exercises"]); + expect(sample.lessonCount).toBe(5); + expect(sample.minutes).toBeGreaterThan(14); + expect(anon.featured.id).toBe("sample"); + + const owner = await t.signUp("owner@example.com"); + const learner = await t.signUp("l@example.com"); + const asOwner = await (await t.call("GET", "/api/library", { cookie: owner })).json(); + expect(asOwner.courses.map((c: { id: string }) => c.id).sort()).toEqual(["gated", "sample", "secret"]); + const asLearner = await (await t.call("GET", "/api/library", { cookie: learner })).json(); + expect(asLearner.courses.map((c: { id: string }) => c.id).sort()).toEqual(["gated", "sample"]); + // manifest collections become rows; restricted courses do not leak into them + expect(asLearner.collections).toEqual([ + { id: "devops", title: "DevOps", courseIds: ["gated"] }, + { id: "functional-programming", title: "Functional programming", courseIds: ["gated"] }, + ]); + expect((await t.call("GET", "/api/courses/secret", { cookie: learner })).status).toBe(404); + expect((await t.call("GET", "/api/courses/secret/lessons/s1", { cookie: learner })).status).toBe(404); + + // continue row: after touching a lesson + await db.sql`INSERT INTO progress (user_id, course_id, lesson_id, state, position_seconds, updated_at) + SELECT id, 'gated', 'g1a', 'done', NULL, '2026-10-30T00:00:00Z' FROM auth_user WHERE email = 'l@example.com'`; + const again = await (await t.call("GET", "/api/library", { cookie: learner })).json(); + expect(again.continue).toEqual([{ courseId: "gated", lessonId: "g1b", lessonTitle: "Then this", moduleTitle: "First", moduleIndex: 0, moduleDone: 1, moduleTotal: 2, progress: 1 / 3 }]); + expect(again.featured.id).toBe("gated"); +}, 30_000); + +forEachDb("search matches titles, pitch and lesson titles", async (db) => { + const t = await makeApp(db, [FIXTURES, await extraCourses()]); + const hits = await (await t.call("GET", "/api/library/search?q=clip")).json(); + expect(hits).toEqual([{ courseId: "sample", courseTitle: "Sample course", course: false, lessons: [{ id: "m1-video", title: "Watch the clip", moduleTitle: "First steps" }] }]); + const tiny = await (await t.call("GET", "/api/library/search?q=TINY")).json(); + expect(tiny[0].course).toBe(true); + expect(await (await t.call("GET", "/api/library/search?q=")).json()).toEqual([]); +}, 30_000); + +forEachDb("course outline carries gating locks and progress", async (db) => { + const t = await makeApp(db, [FIXTURES, await extraCourses()]); + await t.signUp("owner@example.com"); + const learner = await t.signUp("l@example.com"); + const c = await (await t.call("GET", "/api/courses/gated", { cookie: learner })).json(); + expect(c.modules.map((m: { locked: boolean }) => m.locked)).toEqual([false, true]); + expect(c.modules[1].lessons[0].hasDeck).toBe(true); + expect(c.canEdit).toBe(false); + expect(c.diagnostics).toEqual([]); + expect((await t.call("GET", "/api/courses/gated")).status).toBe(404); // signed-in only + const locked = await (await t.call("GET", "/api/courses/gated/lessons/g2a", { cookie: learner })).json(); + expect(locked.locked).toBe(true); + expect(locked.markdown).toBeNull(); +}, 30_000); diff --git a/packages/feature-learner/test/courses.ts b/packages/feature-learner/test/courses.ts new file mode 100644 index 0000000..5540b14 --- /dev/null +++ b/packages/feature-learner/test/courses.ts @@ -0,0 +1,48 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** Writes extra test courses to a temp dir: a restricted one and a gated one with a deck on a module. */ +export async function extraCourses(): Promise { + const dir = mkdtempSync(join(tmpdir(), "stc-courses-")); + await Bun.write(join(dir, "secret/stcourse.yaml"), `schema: 1 +id: secret +title: Secret course +visibility: restricted +collections: [DevOps] +modules: + - id: m1 + title: Only module + lessons: + - { id: s1, title: Hidden lesson, type: text, file: a.md } +`); + await Bun.write(join(dir, "secret/a.md"), "# Hidden\n\nwords here"); + await Bun.write(join(dir, "gated/stcourse.yaml"), `schema: 1 +id: gated +title: Gated course +pitch: Learn things in order +visibility: signed-in +gating: sequential +collections: [DevOps, Functional programming] +decks: + - { id: dk, file: "Æble slides/deck one.pdf", title: Deck one, attachTo: g2 } +attachments: + - { file: "Æble slides/cheat sheet.pdf", title: Cheat sheet, module: g1 } +modules: + - id: g1 + title: First + lessons: + - { id: g1a, title: Start here, type: text, file: "lessons/one.md" } + - { id: g1b, title: Then this, type: text, file: "lessons/two.md" } + - id: g2 + title: Second + lessons: + - { id: g2a, title: Locked lesson, type: text, file: "lessons/three.md" } +`); + await Bun.write(join(dir, "gated/lessons/one.md"), "# One\n\nSee [two](two.md).\n"); + await Bun.write(join(dir, "gated/lessons/two.md"), "# Two\n"); + await Bun.write(join(dir, "gated/lessons/three.md"), "# Three\n\nsecret content"); + await Bun.write(join(dir, "gated/Æble slides/deck one.pdf"), "%PDF-1.4\n"); + await Bun.write(join(dir, "gated/Æble slides/cheat sheet.pdf"), "%PDF-1.4\ncheat"); + return dir; +} diff --git a/packages/feature-learner/test/gating.test.ts b/packages/feature-learner/test/gating.test.ts new file mode 100644 index 0000000..cf67e3b --- /dev/null +++ b/packages/feature-learner/test/gating.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from "bun:test"; +import { parseManifest } from "@stc/course-format"; +import { moduleLocks } from "../src/server/gating"; + +const m = (gating: string) => { + const r = parseManifest(`schema: 1\nid: g\ntitle: G\ngating: ${gating}\nmodules:\n - { id: a, title: A, lessons: [{ id: a1, title: A1, type: exercise }, { id: a2, title: A2, type: exercise }] }\n - { id: b, title: B, lessons: [{ id: b1, title: B1, type: exercise }] }\n - { id: c, title: C, lessons: [{ id: c1, title: C1, type: exercise }] }\n`); + if (!r.ok) throw new Error(r.errors.join()); + return r.manifest; +}; + +test("no gating unlocks everything", () => { + expect(moduleLocks(m("none"), {})).toEqual([false, false, false]); +}); +test("sequential gating locks until the previous module is done", () => { + expect(moduleLocks(m("sequential"), {})).toEqual([false, true, true]); + expect(moduleLocks(m("sequential"), { a1: { state: "done" } })).toEqual([false, true, true]); + expect(moduleLocks(m("sequential"), { a1: { state: "done" }, a2: { state: "done" } })).toEqual([false, false, true]); + // done later modules do not unlock past an unfinished one + expect(moduleLocks(m("sequential"), { a1: { state: "done" }, a2: { state: "done" }, c1: { state: "done" } })).toEqual([false, false, true]); + expect(moduleLocks(m("sequential"), {}, true)).toEqual([false, false, false]); +}); diff --git a/packages/feature-learner/test/lesson.test.ts b/packages/feature-learner/test/lesson.test.ts new file mode 100644 index 0000000..dba956e --- /dev/null +++ b/packages/feature-learner/test/lesson.test.ts @@ -0,0 +1,47 @@ +import { expect } from "bun:test"; +import { forEachDb } from "./helpers"; +import { FIXTURES, makeApp } from "./app"; +import { extraCourses } from "./courses"; + +forEachDb("lesson payloads for every sample lesson type", async (db) => { + const t = await makeApp(db); + const get = async (id: string, cookie?: string) => (await t.call("GET", `/api/courses/sample/lessons/${id}`, { cookie })).json(); + + const intro = await get("m1-intro"); + expect(intro.markdown).toContain("Welcome to the sample course"); + expect(intro.prev).toBeNull(); + expect(intro.next).toEqual({ id: "m1-video", title: "Watch the clip" }); + expect(intro.code).toEqual({ moduleId: "m1", dir: "code/S01 - First steps", command: "sh hello.sh" }); + expect(intro.lessonFiles["lessons/01-intro.md"]).toBe("m1-intro"); + + const video = await get("m1-video"); + expect(video.video).toEqual({ url: "/api/media/sample/media/clip.mp4", file: "media/clip.mp4", duration: 2 }); + expect(video.transcript).toContain("test pattern"); + expect(video.decks).toEqual([{ id: "d1", title: "deck", url: "/api/media/sample/media/deck.pdf", attachTo: "m1-video" }]); + + const ex = await get("m2-ex-add"); + expect(ex.markdown?.startsWith("### Exercise 2.1")).toBe(true); + expect(ex.markdown).not.toContain("Hints"); + expect(ex.hints.map((h: { label: string }) => h.label)).toEqual(["nudge", "near-solution"]); + expect(ex.browser.language).toBe("js"); + expect(ex.browser.starter).toContain("a - b"); + expect(ex.browser.tests).toContain("export const tests"); + expect(ex.browser.solution).toBeNull(); // not an author + expect(ex.localCommand).toBe("node --test"); + + const owner = await t.signUp("owner@example.com"); + expect((await get("m2-ex-add", owner)).browser.solution).toContain("a + b"); + expect((await t.call("GET", "/api/courses/sample/lessons/nope")).status).toBe(404); +}, 30_000); + +forEachDb("module decks and attachments with non-ASCII paths", async (db) => { + const t = await makeApp(db, [FIXTURES, await extraCourses()]); + const owner = await t.signUp("owner@example.com"); + const l = await (await t.call("GET", "/api/courses/gated/lessons/g2a", { cookie: owner })).json(); + expect(l.locked).toBe(false); // owners bypass gating + expect(l.decks[0].url).toBe("/api/media/gated/%C3%86ble%20slides/deck%20one.pdf"); + const first = await (await t.call("GET", "/api/courses/gated/lessons/g1a", { cookie: owner })).json(); + expect(first.attachments).toEqual([{ title: "Cheat sheet", file: "Æble slides/cheat sheet.pdf", url: "/api/media/gated/%C3%86ble%20slides/cheat%20sheet.pdf", size: 14 }]); + const media = await t.call("GET", first.attachments[0].url, { cookie: owner }); + expect(media.status).toBe(200); +}, 30_000);