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]>
56 lines
2.5 KiB
JavaScript
56 lines
2.5 KiB
JavaScript
// 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 };
|