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.
This commit is contained in:
2026-08-25 12:08:12 -05:00
parent 895e16fea4
commit 611e1ac4ba
6 changed files with 437 additions and 33 deletions
+31 -33
View File
@@ -245,11 +245,22 @@ app.get('/api/progress', wrap(studentSession), async (req, res) => {
res.json({ total, completed, details: progress });
});
app.post('/api/progress/record', wrap(studentSession), async (req, res) => {
// Phase 5B — found by testing: this route's handler was never passed through wrap() (a
// leftover from Phase 1/2A, before that convention existed), so any unexpected internal error
// here — including Postgres rejecting a malformed environment_id, a 22P02 "invalid input
// syntax for type uuid" — became an unhandled promise rejection that CRASHED THE ENTIRE
// PLATFORM API PROCESS for every student, not just a 500 for this one request. Now wrapped
// like every route added since Phase 3B, plus explicit UUID-shape validation so a bad
// environment_id is a clean 400 rather than ever reaching Postgres as a type error at all.
const ENV_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
app.post('/api/progress/record', wrap(studentSession), wrap(async (req, res) => {
const { scenario_id, status, environment_id } = req.body || {};
if (!scenario_id || !['completed', 'in_progress'].includes(status)) {
return res.status(400).json({ error: 'scenario_id and a valid status are required' });
}
if (environment_id && !ENV_ID_RE.test(environment_id)) {
return res.status(400).json({ error: 'environment_id must be a valid UUID' });
}
const pool = getPool();
const passed = status === 'completed';
@@ -293,26 +304,17 @@ app.post('/api/progress/record', wrap(studentSession), async (req, res) => {
}
res.json({ ok: true });
});
}));
// ═══════════════════════════════════════════════════════════════════════════════════════
// STUDENT-FACING: environment lifecycle
// ═══════════════════════════════════════════════════════════════════════════════════════
async function publicEnvView(env) {
if (!env) return { status: 'none' };
const view = { status: env.status };
if (env.status === 'running') {
let token = env.routing_token && env.routing_token !== '[redacted]' ? env.routing_token : null;
if (!token) {
const { status, body } = await provisioner.getToken(env.environment_id);
if (status === 200) token = body.routing_token;
}
if (token) view.launch_path = `/lab/${token}/`;
}
if (env.status === 'error') view.error_reason = 'Lab failed to start. You can try launching again.';
return view;
}
// Phase 5B — publicEnvView/the launch sequence now live in environment-view.js so
// selfpaced-routes.js's entitlement-gated launch route can call the identical logic instead of
// a second copy. Behavior here is unchanged from before the extraction.
const { publicEnvView: publicEnvViewFor, launchEnvironmentForStudent } = require('./environment-view');
const publicEnvView = (env) => publicEnvViewFor(provisioner, env);
app.get('/api/environment/status', wrap(studentSession), async (req, res) => {
const { status, body } = await provisioner.listForStudent(req.studentId);
@@ -322,23 +324,8 @@ app.get('/api/environment/status', wrap(studentSession), async (req, res) => {
});
app.post('/api/environment/launch', wrap(studentSession), async (req, res) => {
const existing = await provisioner.listForStudent(req.studentId);
const active = existing.body?.find((e) => ['requested', 'provisioning', 'running', 'resetting'].includes(e.status));
if (active) return res.json(await publicEnvView(active));
const { status, body } = await provisioner.provision(req.studentId);
if (status === 409) {
const retry = await provisioner.listForStudent(req.studentId);
return res.json(await publicEnvView(retry.body?.[0]));
}
// Phase 4.1 §26 — relayed as its own clean status/message, not folded into the generic
// provision_failed 502 below. The student sees "temporarily full, try again shortly," never
// a raw 500/502 — this is expected operational backpressure, not an application error.
if (status === 503 && body?.code === 'CAPACITY_EXCEEDED') {
return res.status(503).json({ error: 'capacity_exceeded', message: 'Lab capacity is temporarily full. Try again shortly.' });
}
if (status !== 201) return res.status(502).json({ error: 'provision_failed', message: body?.error });
res.json(await publicEnvView({ environment_id: body.environment_id, status: body.status, routing_token: body.routing_token }));
const { status, body } = await launchEnvironmentForStudent(provisioner, req.studentId);
res.status(status).json(body);
});
app.post('/api/environment/reset', wrap(studentSession), async (req, res) => {
@@ -397,6 +384,17 @@ require('./admin-classroom-routes')(app, {
instructorSession: wrap(instructorSession), rbac, provisioner, curriculum, getPool, loadProgress, recordAudit,
});
// Phase 5B — programs/enrollment. selfpaced-routes.js is the student-facing half (My
// Programs, program detail, entitlement-gated launch); admin-programs-routes.js is the
// instructor/admin half (program authoring, manual enrollment, activate/cancel);
// integration-routes.js is the separate, non-session website/commerce boundary — see
// docs/PHASE5B_SELF_PACED_ENROLLMENT.md and docs/WEBSITE_ENROLLMENT_INTEGRATION.md.
require('./selfpaced-routes')(app, { studentSession, getPool, curriculum, loadProgress, provisioner, wrap });
require('./admin-programs-routes')(app, {
instructorSession: wrap(instructorSession), rbac, curriculum, getPool, loadProgress, recordAudit,
});
require('./integration-routes')(app, { getPool, recordAudit, wrap });
// Fallback to the platform-shell SPA for any unmatched non-API route
app.get('*', (req, res) => {
res.sendFile(path.join(FRONTEND_DIST, 'index.html'));