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