diff --git a/platform/api/Dockerfile b/platform/api/Dockerfile index 5bb1795..92d71cf 100644 --- a/platform/api/Dockerfile +++ b/platform/api/Dockerfile @@ -33,6 +33,10 @@ COPY platform/api/migrate-sqlite.js ./ # listing/progress-shaping (never executed; setup/validate/teardown stay Lab-only). 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 # needed for a lightweight Node API+static server. COPY --from=frontend-build /fe/dist/ /frontend-dist/ diff --git a/platform/api/src/admin-course-routes.js b/platform/api/src/admin-course-routes.js new file mode 100644 index 0000000..6abddf8 --- /dev/null +++ b/platform/api/src/admin-course-routes.js @@ -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); + }); +}; diff --git a/platform/api/src/course-routes.js b/platform/api/src/course-routes.js new file mode 100644 index 0000000..e2d1722 --- /dev/null +++ b/platform/api/src/course-routes.js @@ -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 }); + })); +}; diff --git a/platform/api/src/server.js b/platform/api/src/server.js index ea76b1b..6b144f8 100644 --- a/platform/api/src/server.js +++ b/platform/api/src/server.js @@ -395,6 +395,16 @@ require('./admin-programs-routes')(app, { }); 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 // instructor/admin half (list, per-student view, resend-activation, send-enrollment- // confirmation); the actual delivery worker starts in main() below, alongside the existing