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:
@@ -0,0 +1,227 @@
|
||||
// Phase 5B §3/§10/§14 — enrollment lifecycle. A student's academic access to a program,
|
||||
// separate from cohort membership (cohort_enrollments, untouched). All status transitions are
|
||||
// server-side only — nothing here accepts a client-supplied status.
|
||||
//
|
||||
// Errors are thrown as `Error` with a `.code` the route layer maps to an HTTP status, the same
|
||||
// convention classroom.js established in Phase 5A (NOT_FOUND/INVALID_STATE/ALREADY_LIVE).
|
||||
|
||||
function err(code, message) {
|
||||
return Object.assign(new Error(message || code), { code });
|
||||
}
|
||||
|
||||
// Pure and derived-at-read-time, matching classroom.js's derivePresence — 'expired' is never
|
||||
// written to the database by a background sweep; it's computed here from access_ends_at
|
||||
// whenever an enrollment is read, so there's no stale/out-of-sync value to worry about. The
|
||||
// one status this function can "see" that the stored column doesn't is exactly that: whether a
|
||||
// still-`active` row has actually run out its access window.
|
||||
function deriveEnrollmentAccess(enrollment, now = new Date()) {
|
||||
const { status, starts_at: startsAt, access_ends_at: accessEndsAt } = enrollment;
|
||||
if (status === 'cancelled') return { effective_status: 'cancelled', can_launch: false, reason: 'cancelled' };
|
||||
if (status === 'completed') return { effective_status: 'completed', can_launch: false, reason: 'completed' };
|
||||
if (status === 'pending') return { effective_status: 'pending', can_launch: false, reason: 'pending_activation' };
|
||||
// status === 'active' below.
|
||||
if (accessEndsAt && new Date(accessEndsAt) <= now) {
|
||||
return { effective_status: 'expired', can_launch: false, reason: 'access_expired' };
|
||||
}
|
||||
if (startsAt && new Date(startsAt) > now) {
|
||||
return { effective_status: 'active', can_launch: false, reason: 'not_yet_started' };
|
||||
}
|
||||
return { effective_status: 'active', can_launch: true, reason: null };
|
||||
}
|
||||
|
||||
async function getEnrollment(pool, enrollmentId) {
|
||||
const { rows } = await pool.query(`SELECT * FROM enrollments WHERE enrollment_id = $1`, [enrollmentId]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function listEnrollmentsForStudent(pool, studentId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT e.*, p.name AS program_name, p.family AS program_family
|
||||
FROM enrollments e JOIN programs p ON p.program_id = e.program_id
|
||||
WHERE e.student_id = $1 ORDER BY e.enrolled_at DESC`,
|
||||
[studentId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function listEnrollmentsForProgram(pool, programId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT e.*, s.display_name AS student_display_name FROM enrollments e
|
||||
JOIN students s ON s.student_id = e.student_id
|
||||
WHERE e.program_id = $1 ORDER BY e.enrolled_at DESC`,
|
||||
[programId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Admin manual enrollment (§11) — the program is snapshotted into the new row exactly the way
|
||||
// createFromIntegrationEvent below does, so both creation paths produce identically-shaped
|
||||
// enrollments. Throws PROGRAM_NOT_FOUND / PROGRAM_INACTIVE / ALREADY_ENROLLED (the DB's
|
||||
// one-open-enrollment-per-program constraint, translated to a clean error instead of a raw
|
||||
// 23505).
|
||||
async function createManualEnrollment(pool, { studentId, programId, status, startsAt, accessEndsAt, deliveryMode, externalReference, metadata, createdBy }) {
|
||||
const { rows: programRows } = await pool.query(`SELECT * FROM programs WHERE program_id = $1`, [programId]);
|
||||
const program = programRows[0];
|
||||
if (!program) throw err('PROGRAM_NOT_FOUND', 'No such program.');
|
||||
if (program.status !== 'active') throw err('PROGRAM_INACTIVE', 'This program is not currently active.');
|
||||
const initialStatus = status === 'active' ? 'active' : 'pending';
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO enrollments (student_id, program_id, delivery_mode, program_version, curriculum_path, status, source, external_reference, starts_at, access_ends_at, activated_at, metadata, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'admin', $7, $8, $9, $10, $11, $12) RETURNING *`,
|
||||
[studentId, programId, deliveryMode || program.delivery_mode, program.version, program.curriculum_path, initialStatus,
|
||||
externalReference || null, startsAt || null, accessEndsAt || null, initialStatus === 'active' ? new Date() : null,
|
||||
metadata ? JSON.stringify(metadata) : null, createdBy || null]
|
||||
);
|
||||
return rows[0];
|
||||
} catch (e) {
|
||||
if (e.code === '23505') throw err('ALREADY_ENROLLED', 'This student already has an open enrollment in this program.');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// §12/§14 — the website/integration boundary's enrollment creation. Idempotent by construction:
|
||||
// the DB-level unique index on external_event_id (not a SELECT-then-INSERT check) is what
|
||||
// actually makes a concurrent duplicate submission safe, not just a sequential retry. Student
|
||||
// resolution is create-or-match (§13): an existing student (matched by email OR
|
||||
// login_identifier) is reused; no match creates a new one via the exact same credential/token
|
||||
// machinery admin-created students already use (students-auth.js) — this endpoint is a
|
||||
// trusted, authenticated, server-to-server caller, the same trust level an instructor's
|
||||
// "add student" admin action already has, not a public signup form.
|
||||
async function createFromIntegrationEvent(pool, studentsAuth, { eventId, source, studentEmail, studentLoginIdentifier, studentDisplayName, programId, status }) {
|
||||
if (!eventId) throw err('EVENT_ID_REQUIRED', 'event_id is required for idempotent enrollment events.');
|
||||
const { rows: programRows } = await pool.query(`SELECT * FROM programs WHERE program_id = $1`, [programId]);
|
||||
const program = programRows[0];
|
||||
if (!program) throw err('PROGRAM_NOT_FOUND', 'Unknown program_slug.');
|
||||
if (program.status !== 'active') throw err('PROGRAM_INACTIVE', 'This program is not currently active.');
|
||||
|
||||
// Replay of an already-processed event — return the original result, create nothing.
|
||||
if (eventId) {
|
||||
const { rows: existing } = await pool.query(`SELECT * FROM enrollments WHERE external_event_id = $1`, [eventId]);
|
||||
if (existing[0]) return { enrollment: existing[0], created: false };
|
||||
}
|
||||
|
||||
const student = await resolveOrCreateStudent(pool, studentsAuth, { studentEmail, studentLoginIdentifier, studentDisplayName });
|
||||
const initialStatus = status === 'active' ? 'active' : 'pending';
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO enrollments (student_id, program_id, delivery_mode, program_version, curriculum_path, status, source, external_event_id, activated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (external_event_id) WHERE external_event_id IS NOT NULL DO NOTHING
|
||||
RETURNING *`,
|
||||
[student.student_id, programId, program.delivery_mode, program.version, program.curriculum_path, initialStatus,
|
||||
source || 'integration', eventId || null, initialStatus === 'active' ? new Date() : null]
|
||||
);
|
||||
if (rows[0]) return { enrollment: rows[0], created: true, student };
|
||||
// Lost the ON CONFLICT race (concurrent identical replay) — the winner's row is now there.
|
||||
const { rows: won } = await pool.query(`SELECT * FROM enrollments WHERE external_event_id = $1`, [eventId]);
|
||||
return { enrollment: won[0], created: false, student };
|
||||
} catch (e) {
|
||||
// The one-open-enrollment-per-program index fired instead — a genuinely new event for a
|
||||
// student who already has an open enrollment in this program (e.g. a retried purchase with
|
||||
// a different event id). Idempotent in spirit even though it's not a literal event replay:
|
||||
// return the existing open enrollment rather than erroring or creating a second one.
|
||||
if (e.code === '23505') {
|
||||
const { rows: existingOpen } = await pool.query(
|
||||
`SELECT * FROM enrollments WHERE student_id = $1 AND program_id = $2 AND status IN ('pending','active')`,
|
||||
[student.student_id, programId]
|
||||
);
|
||||
if (existingOpen[0]) return { enrollment: existingOpen[0], created: false, student };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Matches by email OR login_identifier; creates via students-auth's own credential-issuing path
|
||||
// on no match (an activation token is generated exactly like an admin-created student, and
|
||||
// handed back so a future email/notification step — not built in this phase — could relay it).
|
||||
// A second concurrent call resolving the "same new student" case falls back to re-matching
|
||||
// after a unique-constraint hit, the identical insert/on-conflict-reselect pattern used above.
|
||||
async function resolveOrCreateStudent(pool, studentsAuth, { studentEmail, studentLoginIdentifier, studentDisplayName }) {
|
||||
const identifier = studentEmail || studentLoginIdentifier;
|
||||
if (!identifier) throw err('STUDENT_IDENTIFIER_REQUIRED', 'student.email or student.login_identifier is required.');
|
||||
const existing = await studentsAuth.getStudentByIdentifier(pool, identifier);
|
||||
if (existing) return existing;
|
||||
|
||||
const { rows: countRows } = await pool.query('SELECT count(*)::int AS n FROM students');
|
||||
const studentId = `student-${String(countRows[0].n + 1).padStart(3, '0')}`;
|
||||
const displayName = studentDisplayName || studentEmail || studentLoginIdentifier;
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO students (student_id, display_name, email, login_identifier) VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||
[studentId, displayName, studentEmail ? String(studentEmail).toLowerCase().trim() : null, studentLoginIdentifier ? String(studentLoginIdentifier).trim() : null]
|
||||
);
|
||||
await studentsAuth.issueCredentialToken(pool, studentId, 'activation'); // token itself isn't returned by the integration endpoint — see docs/WEBSITE_ENROLLMENT_INTEGRATION.md
|
||||
return rows[0];
|
||||
} catch (e) {
|
||||
if (e.code === '23505') {
|
||||
const reMatched = await studentsAuth.getStudentByIdentifier(pool, identifier);
|
||||
if (reMatched) return reMatched;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function activateEnrollment(pool, enrollmentId) {
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE enrollments SET status = 'active', activated_at = now(), updated_at = now()
|
||||
WHERE enrollment_id = $1 AND status = 'pending' RETURNING *`,
|
||||
[enrollmentId]
|
||||
);
|
||||
if (!rows[0]) {
|
||||
const existing = await getEnrollment(pool, enrollmentId);
|
||||
throw err(existing ? 'INVALID_STATE' : 'NOT_FOUND', existing ? 'Only a pending enrollment can be activated.' : 'No such enrollment.');
|
||||
}
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async function cancelEnrollment(pool, enrollmentId) {
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE enrollments SET status = 'cancelled', cancelled_at = now(), updated_at = now()
|
||||
WHERE enrollment_id = $1 AND status IN ('pending', 'active') RETURNING *`,
|
||||
[enrollmentId]
|
||||
);
|
||||
if (!rows[0]) {
|
||||
const existing = await getEnrollment(pool, enrollmentId);
|
||||
throw err(existing ? 'INVALID_STATE' : 'NOT_FOUND', existing ? 'Only a pending or active enrollment can be cancelled.' : 'No such enrollment.');
|
||||
}
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
// §10 — completion is entirely server-derived: called opportunistically (right after a
|
||||
// capstone/exam finalizes, and again as a safety-net on every program-status read) rather than
|
||||
// via a client "mark complete" action. Idempotent — the `WHERE status = 'active'` guard means
|
||||
// calling this again after an enrollment is already completed is a no-op, not an error.
|
||||
async function maybeCompleteEnrollment(pool, studentId, programId = null) {
|
||||
const { rows: candidates } = await pool.query(
|
||||
`SELECT e.enrollment_id, e.program_id, p.completion_assessment_id FROM enrollments e
|
||||
JOIN programs p ON p.program_id = e.program_id
|
||||
WHERE e.student_id = $1 AND e.status = 'active' AND p.completion_assessment_id IS NOT NULL
|
||||
${programId ? 'AND e.program_id = $2' : ''}`,
|
||||
programId ? [studentId, programId] : [studentId]
|
||||
);
|
||||
const completed = [];
|
||||
for (const candidate of candidates) {
|
||||
const { rowCount } = await pool.query(
|
||||
`SELECT 1 FROM achievements
|
||||
WHERE student_id = $1 AND achievement_type = 'capstone_completed' AND metadata->>'assessment_definition_id' = $2
|
||||
LIMIT 1`,
|
||||
[studentId, candidate.completion_assessment_id]
|
||||
);
|
||||
if (!rowCount) continue;
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE enrollments SET status = 'completed', completed_at = now(), updated_at = now()
|
||||
WHERE enrollment_id = $1 AND status = 'active' RETURNING *`,
|
||||
[candidate.enrollment_id]
|
||||
);
|
||||
if (rows[0]) completed.push(rows[0]);
|
||||
}
|
||||
return completed;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
deriveEnrollmentAccess, getEnrollment, listEnrollmentsForStudent, listEnrollmentsForProgram,
|
||||
createManualEnrollment, createFromIntegrationEvent, activateEnrollment, cancelEnrollment,
|
||||
maybeCompleteEnrollment,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,63 @@
|
||||
// Phase 5B — combines an enrollment row with real curriculum/progress/achievement state into
|
||||
// one "program status" view: access/launch eligibility, stage/percent progress, and a
|
||||
// deterministic next-activity (resume) target. Shared by the student self-paced routes and the
|
||||
// admin program/enrollment routes so both surfaces show identical information computed one way,
|
||||
// not two independently-maintained views that could drift.
|
||||
|
||||
const { deriveEnrollmentAccess, maybeCompleteEnrollment } = require('./enrollment');
|
||||
const { computeCurriculumState } = require('./curriculum-rules');
|
||||
|
||||
async function buildProgramStatus(deps, enrollmentIn) {
|
||||
const { pool, curriculum, loadProgress } = deps;
|
||||
let enrollment = enrollmentIn;
|
||||
|
||||
// Opportunistic, idempotent completion check (§10) — safe to run on every read; the
|
||||
// underlying UPDATE's `WHERE status = 'active'` guard makes a repeat call a no-op once an
|
||||
// enrollment is already completed, so this never double-fires or races itself.
|
||||
if (enrollment.status === 'active') {
|
||||
const completed = await maybeCompleteEnrollment(pool, enrollment.student_id, enrollment.program_id);
|
||||
if (completed.length) enrollment = { ...enrollment, ...completed[0] };
|
||||
}
|
||||
|
||||
const access = deriveEnrollmentAccess(enrollment);
|
||||
// Only one curriculum path exists today (curriculum.js) — enrollment.curriculum_path is the
|
||||
// snapshot that would let a second path be resolved here later without touching this
|
||||
// function's shape; see docs/PHASE5B_SELF_PACED_ENROLLMENT.md §5.
|
||||
const path = curriculum.loadCurriculumPath();
|
||||
const progress = await loadProgress(pool, enrollment.student_id);
|
||||
|
||||
const capstoneStatus = enrollment.status === 'completed' ? 'completed' : undefined;
|
||||
const state = path ? computeCurriculumState(path, progress, capstoneStatus) : null;
|
||||
|
||||
let nextActivity = null;
|
||||
if (state) {
|
||||
const currentStage = state.stages.find((s) => s.id === state.current_stage);
|
||||
if (currentStage?.next_scenario) {
|
||||
nextActivity = { type: 'scenario', scenario_id: currentStage.next_scenario, stage_id: currentStage.id };
|
||||
} else if (state.capstone && state.capstone.status === 'unlocked') {
|
||||
nextActivity = { type: 'capstone', assessment_definition_id: state.capstone.assessment_definition_id };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
enrollment_id: enrollment.enrollment_id,
|
||||
program_id: enrollment.program_id,
|
||||
program_name: enrollment.program_name || null,
|
||||
program_family: enrollment.program_family || null,
|
||||
status: enrollment.status,
|
||||
effective_status: access.effective_status,
|
||||
can_launch: access.can_launch,
|
||||
blocked_reason: access.reason,
|
||||
delivery_mode: enrollment.delivery_mode,
|
||||
starts_at: enrollment.starts_at,
|
||||
access_ends_at: enrollment.access_ends_at,
|
||||
enrolled_at: enrollment.enrolled_at,
|
||||
activated_at: enrollment.activated_at,
|
||||
completed_at: enrollment.completed_at,
|
||||
cancelled_at: enrollment.cancelled_at,
|
||||
progress: state ? { percent_complete: state.percent_complete, current_stage: state.current_stage, stages: state.stages, capstone: state.capstone } : null,
|
||||
next_activity: nextActivity,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { buildProgramStatus };
|
||||
@@ -179,10 +179,43 @@ module.exports = function createRbac(pool) {
|
||||
};
|
||||
}
|
||||
|
||||
// Phase 5B §11 — a self-paced enrollment may have NO cohort at all, so
|
||||
// requireStudentCohortAccess (which requires a cohort_enrollments row) can't gate it. The
|
||||
// documented safe rule for a plain instructor: they may see/manage an enrollment if EITHER
|
||||
// they already have cohort-based access to that student (the existing relationship, reused
|
||||
// as-is) OR they personally created the enrollment (enrollments.created_by) — never blanket
|
||||
// global visibility into every self-paced student. owner/admin bypass, same as everywhere
|
||||
// else. Resolves the enrollment first (404 if it doesn't exist) and stashes the owning
|
||||
// student_id on the request.
|
||||
function requireEnrollmentAccess(enrollmentIdParam = 'id') {
|
||||
return async (req, res, next) => {
|
||||
const { rows } = await pool.query(`SELECT student_id, created_by FROM enrollments WHERE enrollment_id = $1`, [req.params[enrollmentIdParam]]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'not found' });
|
||||
req.enrollmentStudentId = rows[0].student_id;
|
||||
const instructor = req.instructor;
|
||||
if (['owner', 'admin'].includes(instructor.role)) return next();
|
||||
if (rows[0].created_by === instructor.instructor_id) return next();
|
||||
const { rowCount } = await pool.query(
|
||||
`SELECT 1 FROM cohort_enrollments e JOIN cohort_instructors ci ON ci.cohort_id = e.cohort_id
|
||||
WHERE e.student_id = $1 AND e.enrollment_status = 'enrolled' AND ci.instructor_id = $2`,
|
||||
[rows[0].student_id, instructor.instructor_id]
|
||||
);
|
||||
if (!rowCount) {
|
||||
await recordAudit(pool, {
|
||||
actorType: 'instructor', actorId: instructor.instructor_id, action: 'authz_denied',
|
||||
resourceType: 'enrollment', resourceId: req.params[enrollmentIdParam], result: 'failure', sourceIp: req.ip,
|
||||
});
|
||||
return res.status(403).json({ error: 'forbidden' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
requireRole, requireOwner, requireAdminOrOwner, requireAnyInstructor,
|
||||
requireCohortAccess, requireStudentCohortAccess,
|
||||
requireCohortWriteAccess, requireStudentCohortWriteAccess,
|
||||
requireClassroomAccess, requireClassroomWriteAccess, requireHelpRequestWriteAccess,
|
||||
requireEnrollmentAccess,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// 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);
|
||||
}));
|
||||
};
|
||||
+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