Files
rootnode-academy/platform/api/src/enrollment.js
T
ced4568andClaude Sonnet 5 3510979196 feat(platform): business/product architecture — organizations, guardians, offerings, registrations
- organizations, locations, guardians, student_guardians (033) — additive, no existing
  table's meaning changes. Widens programs.family to accept 'bootcamp'.
- curriculum_packages/curriculum_package_items, offerings, instructor_offerings,
  offering_registrations, agreement_definitions/agreement_acceptances (034) — the full
  purchasable-delivery model. Server-defined status transitions only (no client-controlled
  offering/registration status). Capacity uses a single atomic UPDATE (reserved_seats),
  never a SELECT-count-then-INSERT race.
- offerings.js — business logic: reserveSeat/releaseSeat, transitionOffering/
  transitionRegistration (fixed transition tables), convertRegistrationToEnrollment
  (reuses enrollment.js's existing resolveOrCreateStudent/createManualEnrollment rather
  than duplicating student-matching logic).
- enrollment.js — resolveOrCreateStudent gains an optional, default-off returnToken param
  (used only by offerings.js's admin-only conversion route; the public website integration
  endpoint's contract is completely unchanged, per docs/WEBSITE_ENROLLMENT_INTEGRATION.md).
- admin-offerings-routes.js — the full admin/owner API surface. Registration/guardian
  detail is admin/owner only; offering/location listing is any instructor (access_notes
  stripped for a plain instructor).
- A real bug found and fixed during this phase's own smoke testing before any commit: the
  registration route originally reserved a seat BEFORE validating the registration's own
  fields, letting a validation failure consume a real seat and get masked behind a false
  OFFERING_FULL under a full offering. Fixed: validate (create) first, reserve second, roll
  back the registration row if reservation then genuinely fails.

See docs/ROOTNODE_ACADEMY_BUSINESS_AND_PRODUCT_ARCHITECTURE.md and
docs/OFFERING_AND_REGISTRATION_MODEL.md.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 22:43:52 -05:00

256 lines
15 KiB
JavaScript

// 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, contactEmail, 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';
// §10 — contactEmail (an integration-event-supplied "who to notify about THIS enrollment,"
// e.g. a guardian's address for a minor with no email of their own) is stored on the
// enrollment's existing metadata JSONB, never written to students.email — see
// enrollmentContactEmail() below and notifications.js's resolveRecipient, the only two places
// this value is ever read back out. Not a guardian account, not a linked identity — just a
// delivery address for this one enrollment's notifications.
const metadata = contactEmail ? { contact_email: String(contactEmail).trim() } : null;
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, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
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, metadata ? JSON.stringify(metadata) : 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.
// `returnToken` (default false) attaches the raw activation token to the returned row as
// `_activationToken` when a NEW student is actually created. Default OFF and never passed by
// createFromIntegrationEvent — the public website integration endpoint's own contract
// (docs/WEBSITE_ENROLLMENT_INTEGRATION.md) explicitly never returns this value; only Phase 7's
// admin-only registration-conversion route (offerings.js) opts in, the same trust level an
// instructor manually creating a student already has via POST /api/admin/students (which
// already returns activation_token directly).
async function resolveOrCreateStudent(pool, studentsAuth, { studentEmail, studentLoginIdentifier, studentDisplayName }, { returnToken = false } = {}) {
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]
);
const { token } = await studentsAuth.issueCredentialToken(pool, studentId, 'activation'); // token itself isn't returned by the integration endpoint — see docs/WEBSITE_ENROLLMENT_INTEGRATION.md
return returnToken ? { ...rows[0], _activationToken: token } : 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;
}
// Phase 5C §10 — the one place `enrollments.metadata.contact_email` is read back out (its only
// writer is createFromIntegrationEvent/createManualEnrollment above). Kept as a named accessor
// rather than inline `metadata->>'contact_email'` scattered across notifications.js so this
// transitional, non-identity field has exactly one documented meaning and one call site per
// direction (write here, read here).
function enrollmentContactEmail(enrollment) {
return enrollment?.metadata?.contact_email || null;
}
module.exports = {
deriveEnrollmentAccess, getEnrollment, listEnrollmentsForStudent, listEnrollmentsForProgram,
createManualEnrollment, createFromIntegrationEvent, activateEnrollment, cancelEnrollment,
maybeCompleteEnrollment, enrollmentContactEmail,
// Phase 7 — offerings.js's registration-to-enrollment conversion reuses this exact
// create-or-match logic rather than duplicating it (a registration's eventual student is
// resolved the same way an integration-event or manual enrollment's student already is).
resolveOrCreateStudent,
};