// 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, is_required: activity.is_required, complete: isActivityComplete(activity, ctx), body: content?.shared || null, ...(forInstructor ? { instructor_notes: content?.instructorNotes || null } : {}), }; } // Phase 6B §18/§27 — an optional activity (independent-practice duplicates, advanced/CKS-exam- // style extras) counts toward the displayed total/percent but NOT toward whether the module // itself is "complete." Every Phase 6A activity has is_required=true (the column's default, // unaffected by this), so this is purely additive — nothing that was complete before becomes // incomplete now. function totals(items) { const total = items.length; const completed = items.filter((i) => i.complete).length; const required = items.filter((i) => i.is_required !== false); const requiredComplete = required.filter((i) => i.complete).length; return { total, completed, pct: total ? Math.round((completed / total) * 100) : 0, complete: required.length > 0 && requiredComplete === required.length, }; } // 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 };