- enrollment.js: server-only status transitions (pending->active->completed, cancel), deriveEnrollmentAccess (pure, derived-at-read-time like classroom.js's derivePresence — 'expired' is never stored, computed from access_ends_at), maybeCompleteEnrollment (idempotent, server-derived from the existing capstone achievement). - environment-view.js: publicEnvView/launchEnvironmentForStudent extracted out of server.js's original /api/environment/launch route body so the new entitlement-gated self-paced launch reuses the identical disposable-Lab sequence instead of a second copy — behavior for the existing cohort/pilot route is completely unchanged. - program-status.js: combines an enrollment with real curriculum/progress state into one view (access, percent-complete, current stage, deterministic next-activity/resume target) — shared by the student and admin surfaces so both show identical information. - selfpaced-routes.js: GET /api/student/programs(/:id), POST .../launch — entitlement enforced server-side before ever touching the Provisioner, never just hidden in the UI. - rbac.js: requireEnrollmentAccess — a plain instructor may manage an enrollment they created or that belongs to a student sharing one of their cohorts; a self-paced enrollment can have no cohort at all, so the existing cohort-only checks can't cover it. - server.js: found and fixed a real bug — POST /api/progress/record's handler predated the wrap() convention, so an unexpected internal error (a malformed environment_id) crashed the entire process instead of just 500ing that request. Now wrapped + input-validated.
45 lines
2.7 KiB
JavaScript
45 lines
2.7 KiB
JavaScript
// Phase 5B §6/§7/§8/§9 — student-facing self-paced program API. Only ever shows a student
|
|
// programs THEY hold an enrollment for (never a public catalog — see §22, no public course
|
|
// catalog this phase) and enforces access/launch entitlement server-side, not just by hiding a
|
|
// button (§9: "Hiding a button in React is not access control").
|
|
|
|
const enrollment = require('./enrollment');
|
|
const { buildProgramStatus } = require('./program-status');
|
|
const { launchEnvironmentForStudent } = require('./environment-view');
|
|
|
|
module.exports = function registerSelfPacedRoutes(app, deps) {
|
|
const { studentSession, getPool, curriculum, loadProgress, provisioner, wrap } = deps;
|
|
const statusDeps = { pool: getPool(), curriculum, loadProgress };
|
|
|
|
// §6 — "My Programs": every enrollment this student holds, active and historical alike,
|
|
// annotated with real progress/access/next-activity. The frontend splits active vs completed
|
|
// client-side from the `status`/`effective_status` fields — no separate endpoint needed.
|
|
app.get('/api/student/programs', studentSession, wrap(async (req, res) => {
|
|
const rows = await enrollment.listEnrollmentsForStudent(getPool(), req.studentId);
|
|
const views = await Promise.all(rows.map((e) => buildProgramStatus(statusDeps, e)));
|
|
res.json(views);
|
|
}));
|
|
|
|
app.get('/api/student/programs/:enrollmentId', 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' });
|
|
res.json(await buildProgramStatus(statusDeps, row));
|
|
}));
|
|
|
|
// §8/§9 — the entitlement-gated launch. Delegates to the EXACT same disposable-Lab sequence
|
|
// /api/environment/launch uses (environment-view.js) — a self-paced Lab is not a different
|
|
// kind of Lab, just a gated entry point into the same one. The gate itself (deriveEnrollment
|
|
// Access) is what makes this route different from calling /api/environment/launch directly;
|
|
// that route is unchanged and still works exactly as before for cohort/pilot students.
|
|
app.post('/api/student/programs/:enrollmentId/launch', 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 access = enrollment.deriveEnrollmentAccess(row);
|
|
if (!access.can_launch) {
|
|
return res.status(403).json({ error: 'not_entitled', reason: access.reason });
|
|
}
|
|
const { status, body } = await launchEnvironmentForStudent(provisioner, req.studentId);
|
|
res.status(status).json(body);
|
|
}));
|
|
};
|