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
+4
View File
@@ -33,6 +33,10 @@ COPY platform/api/migrate-sqlite.js ./
# listing/progress-shaping (never executed; setup/validate/teardown stay Lab-only). # listing/progress-shaping (never executed; setup/validate/teardown stay Lab-only).
COPY scenarios/ ./scenarios/ COPY scenarios/ ./scenarios/
# Phase 6A — course/module lesson prose (content.js). Git-tracked Markdown, read-only, same
# reasoning as scenarios/ above: this image only ever reads it, never executes anything from it.
COPY content/ ./content/
# Platform-shell + admin frontend, built in the stage above — served statically, no nginx # Platform-shell + admin frontend, built in the stage above — served statically, no nginx
# needed for a lightweight Node API+static server. # needed for a lightweight Node API+static server.
COPY --from=frontend-build /fe/dist/ /frontend-dist/ COPY --from=frontend-build /fe/dist/ /frontend-dist/
+42
View File
@@ -0,0 +1,42 @@
// Phase 6A §7 — instructor/admin curriculum visibility. Read-only: course/module/activity
// authoring stays Git/migration-based this phase (see docs/COURSE_CONTENT_MODEL.md — no
// WYSIWYG CMS), so there is deliberately no POST/PATCH here, only the views an instructor needs
// to actually teach from: program structure with instructor notes, and per-student progress
// through it so a teacher can see where someone is stuck.
const { buildCourseTree } = require('./course-progress');
module.exports = function registerAdminCourseRoutes(app, deps) {
const { instructorSession, rbac, getPool } = deps;
const { requireAnyInstructor, requireStudentRelationshipAccess } = rbac;
// Same local-wrap convention as admin-programs-routes.js/admin-learning-routes.js — RBAC
// middleware here is plain sync `(req,res,next)`, so this Promise.resolve() guard lets it
// share the same mw.map(wrap) list as the async route handlers below.
function wrap(fn) {
return (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); };
}
const admin = { get: (p, ...mw) => app.get(p, instructorSession, ...mw.map(wrap)) };
// Full structure (courses -> modules -> activities), instructor notes and draft content
// included, with no completion overlay — studentId is deliberately null here (course-
// progress.js's queries tolerate that and just return complete:false everywhere), since this
// is "what does the curriculum look like," not "how is any one student doing."
admin.get('/api/admin/programs/:programId/courses', requireAnyInstructor, async (req, res) => {
const courses = await buildCourseTree(getPool(), { studentId: null, programId: req.params.programId, forInstructor: true });
res.json(courses);
});
// One student's real progress through the same tree. Uses requireStudentRelationshipAccess,
// not requireStudentCohortAccess — a self-paced root_ student typically has NO cohort at all
// (Phase 5B), so the cohort-only check would 403 a plain instructor out of every self-paced
// student's progress. Same rule Phase 5C's notification routes already established: a plain
// instructor may see a student they share a cohort with OR whose enrollment they personally
// created; owner/admin always bypass.
admin.get('/api/admin/students/:id/courses', requireAnyInstructor, requireStudentRelationshipAccess('id'), async (req, res) => {
const programId = req.query.program_id;
if (!programId) return res.status(400).json({ error: 'program_id is required' });
const courses = await buildCourseTree(getPool(), { studentId: req.params.id, programId, forInstructor: true });
res.json(courses);
});
};
+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 });
}));
};
+10
View File
@@ -395,6 +395,16 @@ require('./admin-programs-routes')(app, {
}); });
require('./integration-routes')(app, { getPool, recordAudit, wrap }); require('./integration-routes')(app, { getPool, recordAudit, wrap });
// Phase 6A — course/module/activity content model, layered on top of the existing
// scenario/assessment/achievement systems (never a duplicate of them — see
// docs/COURSE_CONTENT_MODEL.md). course-routes.js is the student-facing half (course tree
// through an enrollment, lesson completion marker); admin-course-routes.js is the instructor
// curriculum view (structure + per-student progress).
require('./course-routes')(app, { studentSession, getPool, wrap });
require('./admin-course-routes')(app, {
instructorSession: wrap(instructorSession), rbac, getPool,
});
// Phase 5C — enrollment operations & communications. notification-routes.js is the // Phase 5C — enrollment operations & communications. notification-routes.js is the
// instructor/admin half (list, per-student view, resend-activation, send-enrollment- // instructor/admin half (list, per-student view, resend-activation, send-enrollment-
// confirmation); the actual delivery worker starts in main() below, alongside the existing // confirmation); the actual delivery worker starts in main() below, alongside the existing