feat(content): add course/module/activity content model

New tables (courses, course_modules, module_activities,
activity_completions) layered over the existing scenario/assessment/
achievement systems — a lab activity references an existing scenario_id,
an assessment/capstone activity references an existing
assessment_definition_id, neither duplicated. Long-form lesson prose
lives in git-tracked Markdown (content/root/), split into shared vs.
instructor-only sections by a '## Instructor Notes' heading convention
(content.js) rather than a second file or a CMS.

course-progress.js derives completion from EXISTING sources only:
auto_scenario reads the progress table, auto_assessment reads
achievements by assessment_definition_id, manual_marker is the one
genuinely new piece of state (for lesson-only activities with nothing
else to derive completion from). Prerequisite locking treats a
zero-content (PLANNED) prerequisite as vacuously satisfied, so an
honestly-unbuilt course never permanently locks everything after it.

Found by testing: computing a level's prerequisite lock and stripping
its working totals in the same map iteration made the result depend on
iteration order. Fixed with two explicit passes.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-08-25 23:29:13 -05:00
co-authored by Claude Sonnet 5
parent f152b9a489
commit fe702754cd
4 changed files with 616 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
// Phase 6A §3 — loads a module_activity's Markdown content file. Same read-only cache pattern
// as curriculum.js (scenarios/bundles): loaded from disk, cached, exposed via a reload for
// tests/dev. Long-form lesson prose lives in git-tracked Markdown (content/root/...), not
// Postgres — see docs/COURSE_CONTENT_MODEL.md for why.
//
// A single convention splits shared (self-paced-visible) content from instructor-only content
// in the SAME file, instead of two files that can drift apart: everything before the first
// "## Instructor Notes" heading is shared; that heading and everything after it is
// instructor-only. Self-paced/student responses never include the instructor_notes field
// regardless of any request parameter — this mirrors the RBAC convention used everywhere else
// in this codebase (identity decides visibility, never a client-supplied flag).
const fs = require('fs');
const path = require('path');
// Same convention as curriculum.js's SCENARIOS_DIR: one level up from src/, where the
// Dockerfile COPYs both scenarios/ and content/ (see platform/api/Dockerfile) — 'content/...'
// paths stored in module_activities.content_path resolve against this, not the actual repo
// root, since that's what the running image actually has on disk.
const ROOT_DIR = process.env.CONTENT_ROOT_DIR || path.join(__dirname, '..');
const INSTRUCTOR_HEADING = /\n## Instructor Notes\b/;
const cache = new Map(); // content_path -> { shared, instructorNotes } | null (missing file)
function splitContent(raw) {
const match = raw.match(INSTRUCTOR_HEADING);
if (!match) return { shared: raw.trim(), instructorNotes: null };
return {
shared: raw.slice(0, match.index).trim(),
instructorNotes: raw.slice(match.index).replace(/^\n/, '').trim(),
};
}
// contentPath is always a repo-relative path (module_activities.content_path, e.g.
// 'content/root/root-10-.../m01-health-probes/01-lesson-....md') — resolved against the repo
// root, never against anything a request supplies, so there's no path-traversal surface here.
function loadContent(contentPath) {
if (!contentPath) return null;
if (cache.has(contentPath)) return cache.get(contentPath);
const abs = path.join(ROOT_DIR, contentPath);
let result = null;
try {
result = splitContent(fs.readFileSync(abs, 'utf8'));
} catch {
result = null; // missing file — a PARTIAL/PLANNED activity may legitimately have no content yet
}
cache.set(contentPath, result);
return result;
}
function reloadCache() {
cache.clear();
}
module.exports = { loadContent, reloadCache };
+177
View File
@@ -0,0 +1,177 @@
// Phase 6A §3/§8 — course/module/activity completion and prerequisite locking, derived
// entirely from EXISTING sources of truth: progress (scenario completion, same table
// curriculum-rules.js already reads), achievements (assessment/capstone passes), and
// activity_completions (the ONLY new state this phase adds, and only for lesson-only
// activities that have nothing else to derive completion from). See
// docs/COURSE_CONTENT_MODEL.md §"completion rules" for the full rationale — this file must
// never become a second, independently-drifting copy of scenario/assessment completion.
const { loadProgress } = require('./progress');
const { loadContent } = require('./content');
function isActivityComplete(activity, ctx) {
switch (activity.completion_behavior) {
case 'auto_scenario':
return ctx.progress[activity.scenario_id]?.status === 'completed';
case 'auto_assessment':
return ctx.passedAssessmentIds.has(activity.assessment_definition_id);
case 'manual_marker':
default:
return ctx.completedActivityIds.has(activity.activity_id);
}
}
// One query pass per student, reused across every activity in every course — same shape as
// curriculum-rules.js's caller-supplied progress map, just extended with the two new sources.
async function loadCompletionContext(pool, studentId) {
const progress = await loadProgress(pool, studentId);
const { rows: achievementRows } = await pool.query(
`SELECT metadata->>'assessment_definition_id' AS assessment_definition_id FROM achievements
WHERE student_id = $1 AND achievement_type IN ('assessment_completed', 'capstone_completed')
AND metadata->>'assessment_definition_id' IS NOT NULL`,
[studentId]
);
const passedAssessmentIds = new Set(achievementRows.map((r) => r.assessment_definition_id));
const { rows: completionRows } = await pool.query(
`SELECT activity_id FROM activity_completions WHERE student_id = $1`, [studentId]
);
const completedActivityIds = new Set(completionRows.map((r) => r.activity_id));
return { progress, passedAssessmentIds, completedActivityIds };
}
// forInstructor: includes draft (unpublished) rows and each activity's instructor_notes.
// A student-facing caller must NEVER pass true — this is the one flag in this module that
// controls what an instructor sees vs. a student, and it comes from server-resolved identity
// (which route called this), never from a request parameter.
function shapeActivity(activity, ctx, forInstructor) {
const content = loadContent(activity.content_path);
return {
activity_id: activity.activity_id,
sequence: activity.sequence,
activity_type: activity.activity_type,
title: activity.title,
status: activity.status,
estimated_duration_minutes: activity.estimated_duration_minutes,
scenario_id: activity.scenario_id,
assessment_definition_id: activity.assessment_definition_id,
completion_behavior: activity.completion_behavior,
complete: isActivityComplete(activity, ctx),
body: content?.shared || null,
...(forInstructor ? { instructor_notes: content?.instructorNotes || null } : {}),
};
}
function totals(items) {
const total = items.length;
const completed = items.filter((i) => i.complete).length;
return { total, completed, pct: total ? Math.round((completed / total) * 100) : 0, complete: total > 0 && completed === total };
}
// A prerequisite with zero content (a PLANNED module/course with nothing built yet) can never
// block anything — otherwise an honestly-empty PLANNED entry would permanently lock every real
// module/course that lists it as a prerequisite. See 022_course_content.sql's header comment
// and docs/COURSE_CONTENT_MODEL.md §"prerequisite/locking rule".
function prerequisitesSatisfied(prereqIds, byId) {
return (prereqIds || []).every((id) => {
const target = byId.get(id);
if (!target) return true; // an unknown/removed prerequisite id never blocks
return target._totals.total === 0 || target._totals.complete;
});
}
// Builds the full course -> module -> activity tree for one student against one program,
// with real completion and prerequisite-based locking. Shared by the student self-paced route
// and the instructor curriculum view — forInstructor controls draft visibility and
// instructor_notes only, never the completion/locking logic itself (both surfaces must agree on
// what's actually true).
async function buildCourseTree(pool, { studentId, programId, forInstructor = false }) {
const ctx = await loadCompletionContext(pool, studentId);
const visClause = forInstructor ? '' : `AND visibility = 'published'`;
const { rows: courseRows } = await pool.query(
`SELECT * FROM courses WHERE program_id = $1 ${visClause} ORDER BY sequence`, [programId]
);
const { rows: moduleRows } = await pool.query(
`SELECT * FROM course_modules WHERE course_id = ANY($1::text[]) ${visClause} ORDER BY sequence`,
[courseRows.map((c) => c.course_id)]
);
const { rows: activityRows } = await pool.query(
`SELECT * FROM module_activities WHERE module_id = ANY($1::text[]) ${visClause} ORDER BY sequence`,
[moduleRows.map((m) => m.module_id)]
);
const activitiesByModule = new Map();
for (const a of activityRows) {
const shaped = shapeActivity(a, ctx, forInstructor);
if (!activitiesByModule.has(a.module_id)) activitiesByModule.set(a.module_id, []);
activitiesByModule.get(a.module_id).push(shaped);
}
const modulesByCourse = new Map();
const moduleById = new Map();
for (const m of moduleRows) {
const activities = activitiesByModule.get(m.module_id) || [];
const shaped = {
module_id: m.module_id, sequence: m.sequence, title: m.title, description: m.description,
objectives: m.objectives, estimated_duration_minutes: m.estimated_duration_minutes,
status: m.status, prerequisites: m.prerequisites,
activities, _totals: totals(activities),
};
moduleById.set(m.module_id, shaped);
if (!modulesByCourse.has(m.course_id)) modulesByCourse.set(m.course_id, []);
modulesByCourse.get(m.course_id).push(shaped);
}
// Locking depends on OTHER modules' _totals, so this has to be two separate passes over the
// map, not one — computing and stripping _totals in the same iteration would make the result
// depend on Map iteration order (a module whose _totals got stripped before a dependent
// module's turn would look like it has zero content). Found by testing.
for (const m of moduleById.values()) {
m.locked = !prerequisitesSatisfied(m.prerequisites, moduleById);
}
for (const m of moduleById.values()) {
m.complete = m._totals.complete;
m.percent_complete = m._totals.pct;
delete m._totals;
}
const courseById = new Map();
const courses = courseRows.map((c) => {
const modules = modulesByCourse.get(c.course_id) || [];
const allActivities = modules.flatMap((m) => m.activities);
const shaped = {
course_id: c.course_id, sequence: c.sequence, title: c.title, description: c.description,
objectives: c.objectives, estimated_duration_hours: c.estimated_duration_hours,
status: c.status, prerequisites: c.prerequisites,
modules, _totals: totals(allActivities),
};
courseById.set(c.course_id, shaped);
return shaped;
});
for (const c of courses) {
c.locked = !prerequisitesSatisfied(c.prerequisites, courseById);
}
for (const c of courses) {
c.complete = c._totals.complete;
c.percent_complete = c._totals.pct;
delete c._totals;
}
return courses;
}
// Deterministic "resume learning" target: the first non-locked, incomplete activity in
// sequence order across the whole tree. Mirrors curriculum-rules.js's nextIncompleteScenario,
// just one level higher (activity, not scenario).
function findResumeTarget(courses) {
for (const course of courses) {
if (course.locked || course.complete) continue;
for (const mod of course.modules) {
if (mod.locked || mod.complete) continue;
const next = mod.activities.find((a) => !a.complete);
if (next) return { course_id: course.course_id, module_id: mod.module_id, activity_id: next.activity_id };
}
}
return null;
}
module.exports = { buildCourseTree, findResumeTarget, loadCompletionContext, isActivityComplete };