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