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]>
61 lines
3.2 KiB
JavaScript
61 lines
3.2 KiB
JavaScript
// 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 });
|
|
}));
|
|
};
|