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