diff --git a/docs/COURSE_CONTENT_MODEL.md b/docs/COURSE_CONTENT_MODEL.md index 2c0f796..642e393 100644 --- a/docs/COURSE_CONTENT_MODEL.md +++ b/docs/COURSE_CONTENT_MODEL.md @@ -79,7 +79,11 @@ decided, and it never invents new state for anything that's independently verifi from (a lesson has no "pass/fail," a reflection has no validator). `POST /api/student/ activities/:id/complete` refuses (409 `not_manually_completable`) for any activity whose `completion_behavior` isn't `manual_marker` — a student can never fake a lab or assessment - completion through this route. + completion through this route. **(Phase 6E)** the route also resolves the activity's own + course/module through the same `buildCourseTree()` lock computation every other route trusts, + and refuses (403 `locked`) if either is locked for that student — a real, pre-existing gap + found and fixed while proving the ROOT 01-04 prerequisite chain; see + `docs/PHASE6E_ROOT_FOUNDATIONS_COMPLETION.md`. Course and module completion are pure aggregates over their activities' completion (`total`/ `completed`/`pct`/`complete` — see `totals()`), never separately stored. diff --git a/platform/api/src/course-routes.js b/platform/api/src/course-routes.js index e2d1722..c3534eb 100644 --- a/platform/api/src/course-routes.js +++ b/platform/api/src/course-routes.js @@ -44,12 +44,32 @@ module.exports = function registerCourseRoutes(app, deps) { // (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. + // + // Phase 6E §11 — found by testing, not assumed: this route previously checked only that the + // activity exists and the student holds an active PROGRAM enrollment, never whether the + // activity's own course/module is actually unlocked for that student — a direct POST could + // mark any manual_marker activity complete regardless of the prerequisite chain, exactly the + // class of gap Phase 6B already fixed once for the capstone's own start route. Proven to + // predate this phase (reproduced against a ROOT 05/06 pair, unrelated to Phase 6E's own new + // content) before fixing — see docs/PHASE6E_ROOT_FOUNDATIONS_COMPLETION.md. Reuses the exact + // same buildCourseTree()/locked computation every other route already trusts, rather than a + // second, duplicated lock check. 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.' }); } + const tree = await buildCourseTree(getPool(), { studentId: req.studentId, programId: activity.program_id, forInstructor: false }); + let course = null, mod = null; + for (const c of tree) { + const m = c.modules.find((x) => x.module_id === activity.module_id); + if (m) { course = c; mod = m; break; } + } + if (!course || !mod) return res.status(404).json({ error: 'not_found' }); + if (course.locked || mod.locked) { + return res.status(403).json({ error: 'locked', message: 'This module is not yet unlocked for this student.' }); + } await getPool().query( `INSERT INTO activity_completions (student_id, activity_id) VALUES ($1, $2) ON CONFLICT (student_id, activity_id) DO NOTHING`,