// Phase 6A §6 — student-facing course/module/activity API. Mounted alongside // selfpaced-routes.js (My Programs) — a course tree is always viewed THROUGH an enrollment the // student actually holds, never a public catalog, same boundary selfpaced-routes.js already // established for programs themselves. const enrollment = require('./enrollment'); const { buildCourseTree, findResumeTarget } = require('./course-progress'); module.exports = function registerCourseRoutes(app, deps) { const { studentSession, getPool, wrap } = deps; // The full course/module/activity tree for one enrollment, with real completion + locking. app.get('/api/student/programs/:enrollmentId/courses', studentSession, wrap(async (req, res) => { const row = await enrollment.getEnrollment(getPool(), req.params.enrollmentId); if (!row || row.student_id !== req.studentId) return res.status(404).json({ error: 'not_found' }); const courses = await buildCourseTree(getPool(), { studentId: req.studentId, programId: row.program_id, forInstructor: false }); res.json({ courses, resume: findResumeTarget(courses) }); })); // Phase 5B §9's rule applies here too: an activity is only ever reachable through an // enrollment the student actually holds — resolved server-side from the activity's own // course, never trusted from anything the client asserts about which program it belongs to. async function requireEnrolledActivity(req, res) { const pool = getPool(); const { rows } = await pool.query( `SELECT ma.*, c.program_id FROM module_activities ma JOIN course_modules cm ON cm.module_id = ma.module_id JOIN courses c ON c.course_id = cm.course_id WHERE ma.activity_id = $1 AND ma.visibility = 'published'`, [req.params.activityId] ); const activity = rows[0]; if (!activity) { res.status(404).json({ error: 'not_found' }); return null; } const { rowCount } = await pool.query( `SELECT 1 FROM enrollments WHERE student_id = $1 AND program_id = $2 AND status = 'active'`, [req.studentId, activity.program_id] ); if (!rowCount) { res.status(403).json({ error: 'not_entitled' }); return null; } return activity; } // §9 — the explicit completion marker, valid ONLY for lesson-only ('manual_marker') activity // types. A 'lab' or 'assessment'/'capstone' activity's completion is derived automatically // (course-progress.js) and can never be set this way — marking one "complete" here would be a // second, forgeable source of truth for something the platform already knows how to verify // for real. app.post('/api/student/activities/:activityId/complete', studentSession, wrap(async (req, res) => { const activity = await requireEnrolledActivity(req, res); if (!activity) return; if (activity.completion_behavior !== 'manual_marker') { return res.status(409).json({ error: 'not_manually_completable', message: 'This activity\'s completion is derived automatically.' }); } await getPool().query( `INSERT INTO activity_completions (student_id, activity_id) VALUES ($1, $2) ON CONFLICT (student_id, activity_id) DO NOTHING`, [req.studentId, activity.activity_id] ); res.json({ ok: true }); })); };