Files
rootnode-academy/platform/api/src/content.js
T
ced4568andClaude Sonnet 5 fe702754cd 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]>
2026-08-25 23:29:13 -05:00

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 };