feat(root): wire course/module/activity routes into the Platform API

course-routes.js — student-facing: the full course tree through an
enrollment (GET /api/student/programs/:id/courses) and the lesson
completion marker (POST /api/student/activities/:id/complete, refused
for anything but a manual_marker activity — a lab/assessment can never
be faked complete through this route).

admin-course-routes.js — instructor-facing, read-only (no WYSIWYG CMS
this phase): program structure with instructor notes
(GET /api/admin/programs/:id/courses) and per-student progress
(GET /api/admin/students/:id/courses), scoped via
requireStudentRelationshipAccess — the same self-paced-no-cohort rule
Phase 5C's notification routes already established, not
requireStudentCohortAccess (which would 403 a plain instructor out of
every self-paced student).

Dockerfile now also COPYs content/ alongside the existing scenarios/ —
same 'read-only, never executed' reasoning as that line.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-08-25 23:29:24 -05:00
co-authored by Claude Sonnet 5
parent fe702754cd
commit e2298e95de
4 changed files with 116 additions and 0 deletions
+60
View File
@@ -0,0 +1,60 @@
// 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 });
}));
};