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
+39
View File
@@ -0,0 +1,39 @@
// Shared "launch/view a student's disposable Lab" logic. Phase 5B extracted this out of
// server.js's original POST /api/environment/launch route body so the new entitlement-gated
// self-paced launch route (selfpaced-routes.js) can reuse the EXACT same Provisioner call and
// response shape instead of a second copy — the underlying disposable-Lab mechanism (Phase
// 2A-4.1) is completely unchanged either way, only who's allowed to reach it differs.
async function publicEnvView(provisioner, 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;
}
async function launchEnvironmentForStudent(provisioner, studentId) {
const existing = await provisioner.listForStudent(studentId);
const active = existing.body?.find((e) => ['requested', 'provisioning', 'running', 'resetting'].includes(e.status));
if (active) return { status: 200, body: await publicEnvView(provisioner, active) };
const { status, body } = await provisioner.provision(studentId);
if (status === 409) {
const retry = await provisioner.listForStudent(studentId);
return { status: 200, body: await publicEnvView(provisioner, retry.body?.[0]) };
}
if (status === 503 && body?.code === 'CAPACITY_EXCEEDED') {
return { status: 503, body: { error: 'capacity_exceeded', message: 'Lab capacity is temporarily full. Try again shortly.' } };
}
if (status !== 201) return { status: 502, body: { error: 'provision_failed', message: body?.error } };
return { status: 200, body: await publicEnvView(provisioner, { environment_id: body.environment_id, status: body.status, routing_token: body.routing_token }) };
}
module.exports = { publicEnvView, launchEnvironmentForStudent };