Files
rootnode-academy/platform/api/src/program-status.js
T
ced4568 611e1ac4ba 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.
2026-08-25 12:08:12 -05:00

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 };