- 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.
40 lines
2.1 KiB
JavaScript
40 lines
2.1 KiB
JavaScript
// 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 };
|