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:
+31
-33
@@ -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'));
|
||||
|
||||
Reference in New Issue
Block a user