- 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.
64 lines
3.1 KiB
JavaScript
64 lines
3.1 KiB
JavaScript
// 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 };
|