Files
rootnode-academy/platform/api/src/selfpaced-routes.js
T
ced4568 611e1ac4ba feat(enrollment): enrollment lifecycle, entitlement gating, self-paced student API
- 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.
2026-08-25 12:08:12 -05:00

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);
}));
};