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
@@ -0,0 +1,266 @@
-- Phase 6A §3 — the course/module/activity content model. This is a STRUCTURAL layer on top
-- of everything Phase 1-5 already built (scenarios/*.json, assessment_definitions, achievements,
-- progress/scenario_attempts) — it never duplicates that content, only references it:
--
-- program (existing, 019_programs.sql)
-- -> course (new)
-- -> course_module (new)
-- -> module_activity (new) -- type lab: scenario_id references scenarios/data/*.json
-- -- type assessment/capstone: assessment_definition_id
-- -- references assessment_definitions (013_assessments.sql)
-- -- everything else: content_path references a Markdown
-- -- file under content/root/ (git-tracked prose, not a
-- -- Postgres blob — see docs/COURSE_CONTENT_MODEL.md)
--
-- Long-form lesson prose deliberately does NOT live in Postgres — only the structural metadata
-- (ordering, type, references, status) does. See docs/COURSE_CONTENT_MODEL.md for the full
-- rationale (git-editable content vs. a CMS) and the Markdown convention (a `## Instructor
-- Notes` heading splits shared content from instructor-only content in the same file).
--
-- No CMS, no WYSIWYG editor tables, no content versioning system beyond the same plain
-- incrementing `version` integer every other definitional table here already uses
-- (assessment_definitions, programs) — bumped by hand when a semantic edit is made, exactly the
-- same convention, not a new one.
CREATE TABLE IF NOT EXISTS courses (
course_id TEXT PRIMARY KEY, -- slug, e.g. 'root-05-kubernetes-foundations'
program_id TEXT NOT NULL REFERENCES programs(program_id),
title TEXT NOT NULL,
description TEXT,
sequence INTEGER NOT NULL, -- position within the program's course list
objectives TEXT[] NOT NULL DEFAULT '{}',
prerequisites TEXT[] NOT NULL DEFAULT '{}', -- course_ids; see docs/COURSE_CONTENT_MODEL.md
-- §"prerequisite/locking rule" for how an
-- empty-content prerequisite is treated
estimated_duration_hours INTEGER,
status TEXT NOT NULL DEFAULT 'planned' CHECK (status IN ('ready', 'partial', 'planned')),
visibility TEXT NOT NULL DEFAULT 'published' CHECK (visibility IN ('published', 'draft')),
version INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_courses_program ON courses(program_id, sequence);
CREATE TABLE IF NOT EXISTS course_modules (
module_id TEXT PRIMARY KEY, -- slug, e.g. 'root-05-m01-pods-and-kubectl-basics'
course_id TEXT NOT NULL REFERENCES courses(course_id),
title TEXT NOT NULL,
description TEXT,
sequence INTEGER NOT NULL, -- position within the course's module list
objectives TEXT[] NOT NULL DEFAULT '{}',
prerequisites TEXT[] NOT NULL DEFAULT '{}', -- module_ids
estimated_duration_minutes INTEGER,
status TEXT NOT NULL DEFAULT 'planned' CHECK (status IN ('ready', 'partial', 'planned')),
visibility TEXT NOT NULL DEFAULT 'published' CHECK (visibility IN ('published', 'draft')),
version INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_modules_course ON course_modules(course_id, sequence);
CREATE TABLE IF NOT EXISTS module_activities (
activity_id TEXT PRIMARY KEY, -- slug, e.g. 'root-10-m01-...-a05-rootnode-probe-port-mismatch'
module_id TEXT NOT NULL REFERENCES course_modules(module_id),
sequence INTEGER NOT NULL, -- position within the module's activity list
activity_type TEXT NOT NULL CHECK (activity_type IN
('lesson', 'demonstration', 'knowledge_check', 'guided_exercise',
'lab', 'reflection', 'assessment', 'capstone')),
title TEXT NOT NULL,
content_path TEXT, -- Markdown file under content/root/, relative to repo root; NULL for a pure lab/assessment reference with no extra prose
scenario_id TEXT, -- 'lab' activities only — references scenarios/data/<id>.json (not a DB FK; that content isn't a DB table)
assessment_definition_id TEXT REFERENCES assessment_definitions(assessment_definition_id), -- 'assessment'/'capstone' activities only
completion_behavior TEXT NOT NULL DEFAULT 'manual_marker' CHECK (completion_behavior IN
('auto_scenario', 'auto_assessment', 'manual_marker')),
estimated_duration_minutes INTEGER,
status TEXT NOT NULL DEFAULT 'planned' CHECK (status IN ('ready', 'partial', 'planned')),
visibility TEXT NOT NULL DEFAULT 'published' CHECK (visibility IN ('published', 'draft')),
version INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT module_activity_lab_has_scenario CHECK (activity_type != 'lab' OR scenario_id IS NOT NULL),
CONSTRAINT module_activity_assessment_has_definition CHECK (activity_type NOT IN ('assessment', 'capstone') OR assessment_definition_id IS NOT NULL)
);
CREATE INDEX IF NOT EXISTS idx_activities_module ON module_activities(module_id, sequence);
CREATE INDEX IF NOT EXISTS idx_activities_scenario ON module_activities(scenario_id);
-- Explicit completion marker — ONLY for activities whose completion_behavior is
-- 'manual_marker' (lesson/demonstration/knowledge_check/guided_exercise/reflection). A 'lab'
-- activity's completion is derived live from the existing progress table
-- (scenario_id -> progress[scenario_id].status = 'completed', exactly what curriculum-rules.js
-- already checks) and an 'assessment'/'capstone' activity's completion is derived live from the
-- existing achievements table — this table is never a second source of truth for either. See
-- docs/COURSE_CONTENT_MODEL.md §"completion rules".
CREATE TABLE IF NOT EXISTS activity_completions (
student_id TEXT NOT NULL REFERENCES students(student_id),
activity_id TEXT NOT NULL REFERENCES module_activities(activity_id),
completed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (student_id, activity_id)
);
CREATE INDEX IF NOT EXISTS idx_activity_completions_student ON activity_completions(student_id);
-- ═══════════════════════════════════════════════════════════════════════════════════════════
-- Seed: the ROOT_ course architecture (docs/ROOT_PROGRAM_ARCHITECTURE.md), all under the
-- existing 'root-kubernetes-foundations' program — root_ is one continuous ladder a student
-- enrolls in once, not twelve separate commercial products, so no programs-table change.
--
-- Every module/activity below references EXISTING scenario/assessment content only — nothing
-- here duplicates or forks scenarios/*.json or assessment_definitions. See
-- docs/ROOT_CURRICULUM_INVENTORY.md for the full inventory this mapping is grounded in.
-- ═══════════════════════════════════════════════════════════════════════════════════════════
INSERT INTO courses (course_id, program_id, title, description, sequence, objectives, prerequisites, estimated_duration_hours, status) VALUES
('root-01-computing-linux-foundations', 'root-kubernetes-foundations', 'Computing & Linux Foundations',
'How a computer actually works, the Linux filesystem and shell, and the command-line fluency everything else in root_ builds on. No lab content exists in this repository yet.',
1, '{}', '{}', NULL, 'planned'),
('root-02-linux-administration-networking', 'root-kubernetes-foundations', 'Linux Administration & Networking',
'Users/permissions, services and systemd, package management, and TCP/IP networking fundamentals. No lab content exists in this repository yet.',
2, '{}', '{}', NULL, 'planned'),
('root-03-git-collaborative-development', 'root-kubernetes-foundations', 'Git & Collaborative Development',
'Version control, branching, code review, and working in a shared repository. No lab content exists in this repository yet.',
3, '{}', '{}', NULL, 'planned'),
('root-04-containers-and-docker', 'root-kubernetes-foundations', 'Containers & Docker',
'What a container actually is, building images, and running/networking containers with Docker, as the on-ramp to Kubernetes. No lab content exists in this repository yet.',
4, '{}', '{}', NULL, 'planned'),
('root-05-kubernetes-foundations', 'root-kubernetes-foundations', 'Kubernetes Foundations',
'Pods, kubectl fluency, labels/selectors, and the core object model — the existing "Foundation" curriculum stage.',
5, '{"Explain what a Pod is and its lifecycle states","Use kubectl confidently to inspect and manipulate cluster objects","Apply labels and selectors to organize resources"}', '{}', 8, 'partial'),
('root-06-kubernetes-application-workloads', 'root-kubernetes-foundations', 'Kubernetes Application Workloads',
'Deployments, scaling, rolling updates, Jobs/CronJobs, and DaemonSets — the existing "Workloads" curriculum stage.',
6, '{"Deploy and scale a stateless application","Perform a rolling update and understand deployment strategies","Distinguish Jobs, CronJobs, and DaemonSets"}', '{root-05-kubernetes-foundations}', 4, 'partial'),
('root-07-kubernetes-networking-config', 'root-kubernetes-foundations', 'Kubernetes Networking & Configuration',
'Services, exposing applications, ConfigMaps, Secrets, and environment configuration — the existing "Networking" and "Configuration" curriculum stages.',
7, '{"Expose a workload with the right Service type","Externalize configuration with ConfigMaps and Secrets"}', '{root-06-kubernetes-application-workloads}', 4, 'partial'),
('root-08-kubernetes-storage-scheduling', 'root-kubernetes-foundations', 'Kubernetes Storage & Scheduling',
'PersistentVolumes/Claims, StorageClasses, taints/tolerations, priority, and disruption budgets. Real scenario content exists in this repository (see docs/ROOT_CURRICULUM_INVENTORY.md) but is not yet wired into a module — Phase 6B.',
8, '{}', '{root-07-kubernetes-networking-config}', NULL, 'planned'),
('root-09-kubernetes-security', 'root-kubernetes-foundations', 'Kubernetes Security',
'RBAC, Pod Security Admission, network policy, supply-chain and runtime hardening — a substantial existing CKS-style scenario bank (22 scenarios) not yet wired into a module — Phase 6B.',
9, '{}', '{root-06-kubernetes-application-workloads}', NULL, 'planned'),
('root-10-kubernetes-troubleshooting-observability', 'root-kubernetes-foundations', 'Kubernetes Troubleshooting & Observability',
'Reading logs, exec/cp into containers, node scheduling, and diagnosing real failures — health probes are this course''s gold-standard module.',
10, '{"Diagnose a Pod stuck in a bad state using kubectl and logs","Explain liveness, readiness, and startup probes and how misconfiguring them causes real outages"}', '{root-06-kubernetes-application-workloads}', 5, 'partial'),
('root-11-devops-platform-engineering', 'root-kubernetes-foundations', 'DevOps & Platform Engineering Foundations',
'CI/CD, infrastructure as code, and GitOps principles. No lab content exists in this repository yet.',
11, '{}', '{root-10-kubernetes-troubleshooting-observability}', NULL, 'planned'),
('root-12-real-ops', 'root-kubernetes-foundations', 'RootNode Real Ops',
'RootNode-authored incident scenarios — operate and recover systems the way a real on-call engineer would, not textbook exercises. The existing "RootNode: Real Ops" curriculum stage.',
12, '{"Recover a cluster from a resource-exhaustion incident","Diagnose and fix a broken deployment without being told what''s wrong","Apply resource defaults that prevent a class of outage from recurring"}', '{root-10-kubernetes-troubleshooting-observability}', 3, 'partial'),
('root-capstone', 'root-kubernetes-foundations', 'ROOT Capstone — Operate and Recover a Production-Like Environment',
'The existing RootNode Capstone assessment, combining skills from every stage into one operational evaluation.',
13, '{"Operate a Kubernetes environment under exam conditions without step-by-step guidance"}', '{root-12-real-ops}', 2, 'partial')
ON CONFLICT (course_id) DO NOTHING;
INSERT INTO course_modules (module_id, course_id, title, description, sequence, objectives, prerequisites, estimated_duration_minutes, status) VALUES
('root-05-m01-pods-and-kubectl-basics', 'root-05-kubernetes-foundations', 'Pods & kubectl Basics',
'The 20 scenarios in the existing "Foundation" curriculum stage, as disposable-Lab activities. No teaching lesson content authored yet — Phase 6B.',
1, '{}', '{}', NULL, 'partial'),
('root-06-m01-deployments-and-workloads', 'root-06-kubernetes-application-workloads', 'Deployments & Workloads',
'The 8 scenarios in the existing "Workloads" curriculum stage, as disposable-Lab activities. No teaching lesson content authored yet — Phase 6B.',
1, '{}', '{}', NULL, 'partial'),
('root-07-m01-services-and-networking', 'root-07-kubernetes-networking-config', 'Services & Networking',
'The 2 scenarios in the existing "Networking" curriculum stage, as disposable-Lab activities. No teaching lesson content authored yet — Phase 6B.',
1, '{}', '{}', NULL, 'partial'),
('root-07-m02-configuration-and-secrets', 'root-07-kubernetes-networking-config', 'Configuration & Secrets',
'The 5 scenarios in the existing "Configuration" curriculum stage, as disposable-Lab activities. No teaching lesson content authored yet — Phase 6B.',
2, '{}', '{}', NULL, 'partial'),
('root-10-m01-health-probes-and-troubleshooting', 'root-10-kubernetes-troubleshooting-observability', 'Kubernetes Health Probes & Troubleshooting',
'The Phase 6A gold-standard module: liveness/readiness/startup probe concepts taught BEFORE the student is asked to fix a real probe misconfiguration in rootnode-probe-port-mismatch. See docs/COURSE_AUTHORING_STANDARD.md — this module is the template for all future RootNode lesson authoring.',
1, '{"Explain why Kubernetes health checks exist and what problem each probe type solves","Distinguish liveness, readiness, and startup probes and choose the right one","Diagnose a probe misconfiguration from Pod status and events, not guesswork"}',
'{root-05-m01-pods-and-kubectl-basics}', 75, 'ready'),
('root-10-m02-general-troubleshooting', 'root-10-kubernetes-troubleshooting-observability', 'General Troubleshooting',
'The 5 scenarios in the existing "Troubleshooting" curriculum stage, as disposable-Lab activities. No teaching lesson content authored yet — Phase 6B.',
2, '{}', '{root-10-m01-health-probes-and-troubleshooting}', NULL, 'partial'),
('root-12-m01-real-ops-incidents', 'root-12-real-ops', 'Real Ops Incidents',
'The 4 scenarios in the existing "RootNode: Real Ops" curriculum stage, as disposable-Lab activities. rootnode-probe-port-mismatch also appears here — deliberate reuse: the gold module teaches the concept in depth, this module exercises it again alongside three other unrelated incident types, the same way the RootNode Capstone pool also reuses it. No teaching lesson content authored yet — Phase 6B.',
1, '{}', '{root-10-m01-health-probes-and-troubleshooting}', NULL, 'partial'),
('root-capstone-m01-operate-and-recover', 'root-capstone', 'Operate and Recover',
'The existing RootNode Capstone assessment (90 minutes, mixed required/random incident pool). No capstone orientation/briefing lesson authored yet — Phase 6B.',
1, '{}', '{root-12-m01-real-ops-incidents}', 90, 'partial')
ON CONFLICT (module_id) DO NOTHING;
-- ── Gold-standard module activities (root-10-m01-health-probes-and-troubleshooting) ──────────
-- Content lives in content/root/root-10-kubernetes-troubleshooting-observability/m01-health-probes/
-- — see docs/COURSE_CONTENT_MODEL.md for the Markdown convention these files follow.
INSERT INTO module_activities (activity_id, module_id, sequence, activity_type, title, content_path, scenario_id, assessment_definition_id, completion_behavior, estimated_duration_minutes, status) VALUES
('root-10-m01-a01-lesson-health-check-concepts', 'root-10-m01-health-probes-and-troubleshooting', 1, 'lesson',
'Why Health Checks Exist & Probe Types',
'content/root/root-10-kubernetes-troubleshooting-observability/m01-health-probes/01-lesson-health-check-concepts.md',
NULL, NULL, 'manual_marker', 15, 'ready'),
('root-10-m01-a02-demonstration-probes-in-action', 'root-10-m01-health-probes-and-troubleshooting', 2, 'demonstration',
'Demonstration: Probes in Action',
'content/root/root-10-kubernetes-troubleshooting-observability/m01-health-probes/02-demonstration-probes-in-action.md',
NULL, NULL, 'manual_marker', 10, 'ready'),
('root-10-m01-a03-knowledge-check', 'root-10-m01-health-probes-and-troubleshooting', 3, 'knowledge_check',
'Knowledge Check: Health Probes',
'content/root/root-10-kubernetes-troubleshooting-observability/m01-health-probes/03-knowledge-check.md',
NULL, NULL, 'manual_marker', 5, 'ready'),
('root-10-m01-a04-guided-exercise-readiness-probe', 'root-10-m01-health-probes-and-troubleshooting', 4, 'guided_exercise',
'Guided Exercise: Configure a Readiness Probe',
'content/root/root-10-kubernetes-troubleshooting-observability/m01-health-probes/04-guided-exercise-readiness-probe.md',
NULL, NULL, 'manual_marker', 15, 'ready'),
('root-10-m01-a05-lab-probe-port-mismatch', 'root-10-m01-health-probes-and-troubleshooting', 5, 'lab',
'Launch Lab: rootnode-probe-port-mismatch',
'content/root/root-10-kubernetes-troubleshooting-observability/m01-health-probes/05-lab-intro.md',
'rootnode-probe-port-mismatch', NULL, 'auto_scenario', 20, 'ready'),
('root-10-m01-a06-reflection-real-ops-takeaway', 'root-10-m01-health-probes-and-troubleshooting', 6, 'reflection',
'Root-Cause Reflection & Real Ops Takeaway',
'content/root/root-10-kubernetes-troubleshooting-observability/m01-health-probes/06-reflection-real-ops-takeaway.md',
NULL, NULL, 'manual_marker', 10, 'ready')
ON CONFLICT (activity_id) DO NOTHING;
-- ── Mechanically-mapped lab activities for the other partial modules ────────────────────────
-- Generated 1:1 from scenarios/rootnode-path.json's existing stage scenario_ids (see
-- docs/ROOT_CURRICULUM_INVENTORY.md) — no new lab content invented, titles taken from each
-- scenario's own JSON. Estimated duration is a rough heuristic from the scenario's declared
-- difficulty (15/20/30 min); refine once real per-activity completion-time data exists.
INSERT INTO module_activities (activity_id, module_id, sequence, activity_type, title, content_path, scenario_id, assessment_definition_id, completion_behavior, estimated_duration_minutes, status, visibility, version) VALUES
('root-05-m01-pods-and-kubectl-basics-a01-pod-basics-mcq', 'root-05-m01-pods-and-kubectl-basics', 1, 'lab', 'Lab: Pod Lifecycle States', NULL, 'pod-basics-mcq', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
('root-05-m01-pods-and-kubectl-basics-a02-kubectl-essentials-mcq', 'root-05-m01-pods-and-kubectl-basics', 2, 'lab', 'Lab: Essential kubectl Commands', NULL, 'kubectl-essentials-mcq', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
('root-05-m01-pods-and-kubectl-basics-a03-labels-selectors-mcq', 'root-05-m01-pods-and-kubectl-basics', 3, 'lab', 'Lab: Labels and Selectors', NULL, 'labels-selectors-mcq', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
('root-05-m01-pods-and-kubectl-basics-a04-init-containers-mcq', 'root-05-m01-pods-and-kubectl-basics', 4, 'lab', 'Lab: Init Containers Behaviour', NULL, 'init-containers-mcq', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
('root-05-m01-pods-and-kubectl-basics-a05-namespaces-basics', 'root-05-m01-pods-and-kubectl-basics', 5, 'lab', 'Lab: Working with Namespaces', NULL, 'namespaces-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
('root-05-m01-pods-and-kubectl-basics-a06-multi-container-pod-basics', 'root-05-m01-pods-and-kubectl-basics', 6, 'lab', 'Lab: Create a Multi-Container Pod', NULL, 'multi-container-pod-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
('root-05-m01-pods-and-kubectl-basics-a07-pod-labeling-basics', 'root-05-m01-pods-and-kubectl-basics', 7, 'lab', 'Lab: Labelling Resources', NULL, 'pod-labeling-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
('root-05-m01-pods-and-kubectl-basics-a08-jsonpath-basics', 'root-05-m01-pods-and-kubectl-basics', 8, 'lab', 'Lab: JSONPath Data Extraction', NULL, 'jsonpath-basics', NULL, 'auto_scenario', 20, 'ready', 'published', 1),
('root-05-m01-pods-and-kubectl-basics-a09-dry-run-manifest-basics', 'root-05-m01-pods-and-kubectl-basics', 9, 'lab', 'Lab: Generating YAML Manifests', NULL, 'dry-run-manifest-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
('root-05-m01-pods-and-kubectl-basics-a10-delete-by-label-basics', 'root-05-m01-pods-and-kubectl-basics', 10, 'lab', 'Lab: Bulk Deletion by Label', NULL, 'delete-by-label-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
('root-05-m01-pods-and-kubectl-basics-a11-annotate-resource-basics', 'root-05-m01-pods-and-kubectl-basics', 11, 'lab', 'Lab: Annotating Kubernetes Resources', NULL, 'annotate-resource-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
('root-05-m01-pods-and-kubectl-basics-a12-init-container-task-basics', 'root-05-m01-pods-and-kubectl-basics', 12, 'lab', 'Lab: Pod with Init Container', NULL, 'init-container-task-basics', NULL, 'auto_scenario', 20, 'ready', 'published', 1),
('root-05-m01-pods-and-kubectl-basics-a13-restart-policy-mcq', 'root-05-m01-pods-and-kubectl-basics', 13, 'lab', 'Lab: Pod Restart Policies', NULL, 'restart-policy-mcq', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
('root-05-m01-pods-and-kubectl-basics-a14-kubectl-output-format-mcq', 'root-05-m01-pods-and-kubectl-basics', 14, 'lab', 'Lab: kubectl Output Formats', NULL, 'kubectl-output-format-mcq', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
('root-05-m01-pods-and-kubectl-basics-a15-kubectl-explain-basics', 'root-05-m01-pods-and-kubectl-basics', 15, 'lab', 'Lab: Exploring the API with kubectl explain', NULL, 'kubectl-explain-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
('root-05-m01-pods-and-kubectl-basics-a16-kubectl-patch-basics', 'root-05-m01-pods-and-kubectl-basics', 16, 'lab', 'Lab: Patching Resources with kubectl patch', NULL, 'kubectl-patch-basics', NULL, 'auto_scenario', 20, 'ready', 'published', 1),
('root-05-m01-pods-and-kubectl-basics-a17-field-selector-basics', 'root-05-m01-pods-and-kubectl-basics', 17, 'lab', 'Lab: Filtering Resources with Field Selectors', NULL, 'field-selector-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
('root-05-m01-pods-and-kubectl-basics-a18-sort-by-basics', 'root-05-m01-pods-and-kubectl-basics', 18, 'lab', 'Lab: Sorting kubectl Output', NULL, 'sort-by-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
('root-05-m01-pods-and-kubectl-basics-a19-kubectl-diff-basics', 'root-05-m01-pods-and-kubectl-basics', 19, 'lab', 'Lab: Previewing Changes with kubectl diff', NULL, 'kubectl-diff-basics', NULL, 'auto_scenario', 20, 'ready', 'published', 1),
('root-05-m01-pods-and-kubectl-basics-a20-static-pods-mcq', 'root-05-m01-pods-and-kubectl-basics', 20, 'lab', 'Lab: Static Pods in Kubernetes', NULL, 'static-pods-mcq', NULL, 'auto_scenario', 20, 'ready', 'published', 1),
('root-06-m01-deployments-and-workloads-a01-deploy-nginx', 'root-06-m01-deployments-and-workloads', 1, 'lab', 'Lab: Deploy and Expose Nginx', NULL, 'deploy-nginx', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
('root-06-m01-deployments-and-workloads-a02-scale-deployment', 'root-06-m01-deployments-and-workloads', 2, 'lab', 'Lab: Scaling a Deployment', NULL, 'scale-deployment', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
('root-06-m01-deployments-and-workloads-a03-rolling-update-task', 'root-06-m01-deployments-and-workloads', 3, 'lab', 'Lab: Perform a Rolling Update', NULL, 'rolling-update-task', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
('root-06-m01-deployments-and-workloads-a04-liveness-probe-task', 'root-06-m01-deployments-and-workloads', 4, 'lab', 'Lab: Configure a Liveness Probe', NULL, 'liveness-probe-task', NULL, 'auto_scenario', 20, 'ready', 'published', 1),
('root-06-m01-deployments-and-workloads-a05-jobs-cronjobs-mcq', 'root-06-m01-deployments-and-workloads', 5, 'lab', 'Lab: Jobs vs CronJobs', NULL, 'jobs-cronjobs-mcq', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
('root-06-m01-deployments-and-workloads-a06-daemonset-mcq', 'root-06-m01-deployments-and-workloads', 6, 'lab', 'Lab: Understanding DaemonSets', NULL, 'daemonset-mcq', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
('root-06-m01-deployments-and-workloads-a07-edit-deployment-basics', 'root-06-m01-deployments-and-workloads', 7, 'lab', 'Lab: Updating Deployments', NULL, 'edit-deployment-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
('root-06-m01-deployments-and-workloads-a08-deployment-strategy-mcq', 'root-06-m01-deployments-and-workloads', 8, 'lab', 'Lab: Deployment Update Strategies', NULL, 'deployment-strategy-mcq', NULL, 'auto_scenario', 20, 'ready', 'published', 1),
('root-07-m01-services-and-networking-a01-services-mcq', 'root-07-m01-services-and-networking', 1, 'lab', 'Lab: Kubernetes Service Types', NULL, 'services-mcq', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
('root-07-m01-services-and-networking-a02-expose-service-basics', 'root-07-m01-services-and-networking', 2, 'lab', 'Lab: Exposing an Application', NULL, 'expose-service-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
('root-07-m02-configuration-and-secrets-a01-configmap-volume', 'root-07-m02-configuration-and-secrets', 1, 'lab', 'Lab: Mount ConfigMap as Volume', NULL, 'configmap-volume', NULL, 'auto_scenario', 20, 'ready', 'published', 1),
('root-07-m02-configuration-and-secrets-a02-resource-limits-task', 'root-07-m02-configuration-and-secrets', 2, 'lab', 'Lab: Resource Requests and Limits', NULL, 'resource-limits-task', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
('root-07-m02-configuration-and-secrets-a03-env-vars-configmap', 'root-07-m02-configuration-and-secrets', 3, 'lab', 'Lab: Environment Variables from ConfigMap', NULL, 'env-vars-configmap', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
('root-07-m02-configuration-and-secrets-a04-secrets-basics', 'root-07-m02-configuration-and-secrets', 4, 'lab', 'Lab: Creating and Using Secrets', NULL, 'secrets-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
('root-07-m02-configuration-and-secrets-a05-pod-env-vars-inline', 'root-07-m02-configuration-and-secrets', 5, 'lab', 'Lab: Pod with Inline Environment Variables', NULL, 'pod-env-vars-inline', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
('root-10-m02-general-troubleshooting-a01-extract-logs-basics', 'root-10-m02-general-troubleshooting', 1, 'lab', 'Lab: Extract Container Logs', NULL, 'extract-logs-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
('root-10-m02-general-troubleshooting-a02-kubectl-cp-basics', 'root-10-m02-general-troubleshooting', 2, 'lab', 'Lab: Copy Files to Containers', NULL, 'kubectl-cp-basics', NULL, 'auto_scenario', 20, 'ready', 'published', 1),
('root-10-m02-general-troubleshooting-a03-exec-command-basics', 'root-10-m02-general-troubleshooting', 3, 'lab', 'Lab: Execute Commands in Pods', NULL, 'exec-command-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
('root-10-m02-general-troubleshooting-a04-node-cordon-basics', 'root-10-m02-general-troubleshooting', 4, 'lab', 'Lab: Cordoning and Uncordoning a Node', NULL, 'node-cordon-basics', NULL, 'auto_scenario', 15, 'ready', 'published', 1),
('root-10-m02-general-troubleshooting-a05-pod-node-assignment-mcq', 'root-10-m02-general-troubleshooting', 5, 'lab', 'Lab: Assigning Pods to Nodes', NULL, 'pod-node-assignment-mcq', NULL, 'auto_scenario', 20, 'ready', 'published', 1),
('root-12-m01-real-ops-incidents-a01-rootnode-runaway-monitor', 'root-12-m01-real-ops-incidents', 1, 'lab', 'Lab: Recover the Monitoring Stack', NULL, 'rootnode-runaway-monitor', NULL, 'auto_scenario', 20, 'ready', 'published', 1),
('root-12-m01-real-ops-incidents-a02-broken-deployment', 'root-12-m01-real-ops-incidents', 2, 'lab', 'Lab: Debug a Failing Deployment', NULL, 'broken-deployment', NULL, 'auto_scenario', 20, 'ready', 'published', 1),
('root-12-m01-real-ops-incidents-a03-rootnode-probe-port-mismatch', 'root-12-m01-real-ops-incidents', 3, 'lab', 'Lab: Stuck Not-Ready: The Probe Port Mismatch', NULL, 'rootnode-probe-port-mismatch', NULL, 'auto_scenario', 20, 'ready', 'published', 1),
('root-12-m01-real-ops-incidents-a04-rootnode-enforce-limits', 'root-12-m01-real-ops-incidents', 4, 'lab', 'Lab: Stop the Next Outage: Enforce Resource Defaults', NULL, 'rootnode-enforce-limits', NULL, 'auto_scenario', 30, 'ready', 'published', 1)
ON CONFLICT (activity_id) DO NOTHING;
-- ── Capstone activity (references the existing assessment engine, no duplicate definition) ──
INSERT INTO module_activities (activity_id, module_id, sequence, activity_type, title, content_path, scenario_id, assessment_definition_id, completion_behavior, estimated_duration_minutes, status) VALUES
('root-capstone-m01-a01-capstone', 'root-capstone-m01-operate-and-recover', 1, 'capstone', 'RootNode Capstone', NULL, NULL, 'rootnode-capstone', 'auto_assessment', 90, 'ready')
ON CONFLICT (activity_id) DO NOTHING;
+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 };