feat(offering): launch readiness, agreement requirements, guardian communications, website registration API

- Registration is now server-authoritative: draft/cancelled/completed/full/window-closed
  offerings reject registration attempts with distinct error codes (assertRegistrationOpen).
- offering_agreement_requirements + conversion-time blocking on missing required
  agreements; accepted_by_type gained 'registrant' for an adult self-accepting before
  their student_id exists.
- computeOfferingReadiness() — a server-derived launch checklist (curriculum, instructor,
  schedule, location, capacity, registration window, agreements, curriculum package).
- Guardian communications wired into the existing Phase 5C notification system: a guardian
  email fallback in resolveRecipient(), a parallel enqueueForRegistration()/
  resolveRegistrationRecipient() pair for pre-account registration-stage notifications
  (registration_received/confirmed/needs_action, agreement_required, camp_operational_update),
  five new RootNode-branded templates. Same table, same worker, same masked admin view —
  no second notification system.
- public-offerings-routes.js: the unauthenticated, explicitly-allowlisted offering read API.
- POST /api/integrations/registrations: the website's idempotent registration submission
  contract, same shared-secret boundary as the existing enrollment integration endpoint.

Two real bugs found and fixed during this phase's own testing before any commit:
(1) the five new notification_type values were wired into the worker before the DB's own
CHECK constraint was widened to accept them — every registration-stage notification was
silently failing until a comprehensive smoke test caught it (036 fixes the constraint;
every previously-silent notification catch now at least logs).
(2) firing a notification right after a registration's insert, before its seat reservation
could still fail and roll the row back, created a real foreign-key violation on rollback —
fixed by reordering createRegistration() to validate -> reserve -> insert -> notify.

See docs/OFFERING_AND_REGISTRATION_MODEL.md and docs/PHASE8_ENROLLMENT_OPERATIONS.md.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-08-26 23:59:11 -05:00
co-authored by Claude Sonnet 5
parent df5e39f767
commit 24eee80354
10 changed files with 585 additions and 24 deletions
+220 -9
View File
@@ -6,6 +6,7 @@
// model's own reasoning.
const enrollmentLib = require('./enrollment');
const notificationsLib = require('./notifications');
function err(code, message) {
return Object.assign(new Error(message || code), { code });
@@ -199,17 +200,59 @@ const REGISTRATION_TRANSITIONS = {
cancelled: [],
};
// Phase 8 §21 — registration availability is server-authoritative, checked here (the one place
// a registration row is actually created), not left to a website's own hidden-button logic.
// Distinct error codes per reason so a caller (website or admin UI) can show something more
// useful than a generic failure.
function assertRegistrationOpen(offering) {
if (offering.status === 'draft') throw err('OFFERING_NOT_OPEN', 'This offering is not yet open for registration.');
if (offering.status === 'cancelled') throw err('OFFERING_CANCELLED', 'This offering has been cancelled.');
if (offering.status === 'completed') throw err('OFFERING_COMPLETED', 'This offering has already completed.');
if (offering.status === 'full') throw err('OFFERING_FULL', 'This offering has no remaining capacity.');
if (!['published', 'enrollment_open'].includes(offering.status)) throw err('OFFERING_NOT_OPEN', 'This offering is not currently open for registration.');
const now = new Date();
if (offering.enrollment_opens_at && now < new Date(offering.enrollment_opens_at)) throw err('REGISTRATION_NOT_YET_OPEN', 'Registration for this offering has not opened yet.');
if (offering.enrollment_closes_at && now > new Date(offering.enrollment_closes_at)) throw err('REGISTRATION_CLOSED', 'Registration for this offering has closed.');
}
// Field validation -> seat reservation -> insert -> notification, strictly in that order. A
// notification is only ever enqueued for a registration that is FULLY, durably committed —
// enqueueing one earlier (e.g. before capacity is confirmed) created a real bug found during
// this phase's own regression testing: a rolled-back registration whose row got deleted after a
// failed seat reservation left behind a notification_events row still pointing at it, and the
// DELETE itself then failed on the very foreign key that row created. Reserving the seat FIRST
// (rolling it back if the insert itself somehow fails) means nothing is ever created that later
// needs to be un-created out from under a row something else already references.
async function createRegistration(pool, { offeringId, guardianId, registrantDisplayName, registrantEmail, isMinor, readinessNotes, accessibilityNotes, emergencyContactName, emergencyContactPhone, source, externalReference }) {
if (!offeringId || !registrantDisplayName) throw err('VALIDATION', 'offeringId and registrantDisplayName are required.');
if (isMinor && !guardianId) throw err('GUARDIAN_REQUIRED', 'A minor registration must include a guardianId.');
const offering = await getOffering(pool, offeringId);
if (!offering) throw err('OFFERING_NOT_FOUND', 'No such offering.');
const { rows } = await pool.query(
`INSERT INTO offering_registrations (offering_id, guardian_id, registrant_display_name, registrant_email, is_minor, readiness_notes, accessibility_notes, emergency_contact_name, emergency_contact_phone, source, external_reference)
VALUES ($1,$2,$3,$4,COALESCE($5,false),$6,$7,$8,$9,COALESCE($10,'admin'),$11) RETURNING *`,
[offeringId, guardianId || null, registrantDisplayName, registrantEmail || null, isMinor ?? null, readinessNotes || null, accessibilityNotes || null, emergencyContactName || null, emergencyContactPhone || null, source || null, externalReference || null]
);
return rows[0];
assertRegistrationOpen(offering);
await reserveSeat(pool, offeringId); // throws OFFERING_FULL if genuinely exhausted; nothing created yet if so
let reg;
try {
const { rows } = await pool.query(
`INSERT INTO offering_registrations (offering_id, guardian_id, registrant_display_name, registrant_email, is_minor, readiness_notes, accessibility_notes, emergency_contact_name, emergency_contact_phone, source, external_reference)
VALUES ($1,$2,$3,$4,COALESCE($5,false),$6,$7,$8,$9,COALESCE($10,'admin'),$11) RETURNING *`,
[offeringId, guardianId || null, registrantDisplayName, registrantEmail || null, isMinor ?? null, readinessNotes || null, accessibilityNotes || null, emergencyContactName || null, emergencyContactPhone || null, source || null, externalReference || null]
);
reg = rows[0];
} catch (e) {
await releaseSeat(pool, offeringId).catch(() => {});
if (e.code === '23505') throw err('DUPLICATE_REGISTRATION', 'A registration with this external_reference already exists for this offering.');
throw e;
}
// Best-effort — a notification failure must never fail the registration itself (matches
// enrollment.js's own precedent of never letting notification concerns block the academic
// action they're merely announcing). Safe to fire now: reg is fully, durably committed.
await notificationsLib.enqueueForRegistration(pool, {
registration: reg, notificationType: 'registration_received',
metadata: { display_name: reg.registrant_display_name, offering_name: offering.name },
}).catch((e) => console.error("[offerings] registration_received notification failed:", e.message));
return reg;
}
async function getRegistration(pool, registrationId) {
const { rows } = await pool.query(`SELECT * FROM offering_registrations WHERE registration_id = $1`, [registrationId]);
@@ -226,7 +269,57 @@ async function transitionRegistration(pool, registrationId, toStatus) {
if (!allowed.includes(toStatus)) throw err('INVALID_TRANSITION', `Cannot move a registration from '${reg.status}' to '${toStatus}'.`);
if (toStatus === 'cancelled' && !['enrollment_activated', 'cancelled'].includes(reg.status)) await releaseSeat(pool, reg.offering_id);
const { rows } = await pool.query(`UPDATE offering_registrations SET status = $2, updated_at = now() WHERE registration_id = $1 RETURNING *`, [registrationId, toStatus]);
return rows[0];
const updated = rows[0];
// Best-effort lifecycle notifications — see createRegistration's own comment on why a
// notification failure never blocks the real state change it's merely announcing.
const offering = await getOffering(pool, reg.offering_id);
if (toStatus === 'confirmed') {
await notificationsLib.enqueueForRegistration(pool, {
registration: updated, notificationType: 'registration_confirmed',
metadata: { display_name: updated.registrant_display_name, offering_name: offering?.name },
}).catch((e) => console.error("[offerings] registration lifecycle notification failed:", e.message));
}
if (toStatus === 'agreements_pending') {
const status = await getRegistrationAgreementStatus(pool, registrationId);
for (const missing of status.missing) {
await notificationsLib.enqueueForRegistration(pool, {
registration: updated, notificationType: 'agreement_required',
metadata: { display_name: updated.registrant_display_name, offering_name: offering?.name, agreement_title: missing.title },
}).catch((e) => console.error("[offerings] agreement_required notification failed:", e.message));
}
}
return updated;
}
// ── Offering agreement requirements (Step 18) ────────────────────────────────────────────────
async function addAgreementRequirement(pool, offeringId, agreementId) {
const { rows: agreementRows } = await pool.query(`SELECT 1 FROM agreement_definitions WHERE agreement_id = $1`, [agreementId]);
if (!agreementRows[0]) throw err('AGREEMENT_NOT_FOUND', 'No such agreement.');
const { rows } = await pool.query(
`INSERT INTO offering_agreement_requirements (offering_id, agreement_id) VALUES ($1, $2) ON CONFLICT DO NOTHING RETURNING *`,
[offeringId, agreementId]
);
return rows[0] || { offering_id: offeringId, agreement_id: agreementId };
}
async function listAgreementRequirementsForOffering(pool, offeringId) {
const { rows } = await pool.query(
`SELECT ad.* FROM offering_agreement_requirements oar JOIN agreement_definitions ad ON ad.agreement_id = oar.agreement_id
WHERE oar.offering_id = $1 ORDER BY ad.title`,
[offeringId]
);
return rows;
}
// Required vs. accepted vs. missing, for one registration — the exact shape both the
// agreements_pending notification loop above and the admin/registration-detail UI need.
async function getRegistrationAgreementStatus(pool, registrationId) {
const reg = await getRegistration(pool, registrationId);
if (!reg) throw err('NOT_FOUND', 'No such registration.');
const required = await listAgreementRequirementsForOffering(pool, reg.offering_id);
const accepted = await listAcceptancesForRegistration(pool, registrationId);
const acceptedIds = new Set(accepted.map((a) => a.agreement_id));
const missing = required.filter((r) => !acceptedIds.has(r.agreement_id));
return { required, accepted, missing };
}
// The one place a registration becomes a real academic enrollment. Reuses
@@ -240,6 +333,12 @@ async function convertRegistrationToEnrollment(pool, studentsAuth, registrationI
if (reg.status !== 'confirmed') throw err('INVALID_STATE', 'A registration must be confirmed before it can become an enrollment.');
const offering = await getOffering(pool, reg.offering_id);
if (!offering) throw err('OFFERING_NOT_FOUND', 'No such offering.');
// Step 18 — a required agreement with no matching acceptance blocks conversion, always (this
// offering's own configured requirements, not a global policy toggle this phase invents).
const agreementStatus = await getRegistrationAgreementStatus(pool, registrationId);
if (agreementStatus.missing.length) {
throw err('AGREEMENTS_INCOMPLETE', `Missing required agreement(s): ${agreementStatus.missing.map((m) => m.title).join(', ')}`);
}
const student = reg.student_id
? (await pool.query(`SELECT * FROM students WHERE student_id = $1`, [reg.student_id])).rows[0]
@@ -279,11 +378,28 @@ async function createAgreementDefinition(pool, { agreementId, title, bodySummary
);
return rows[0];
}
async function listAgreementDefinitions(pool) {
const { rows } = await pool.query(`SELECT * FROM agreement_definitions ORDER BY title`);
return rows;
}
// accepted_by_type: 'student' | 'guardian' | 'instructor_on_behalf' | 'registrant'. 'registrant'
// (Step 7's fix) is the one case allowed with neither a student nor a guardian id — an adult
// self-accepting AT REGISTRATION TIME, before their eventual student_id exists, tied only to
// registrationId. Every other type still requires a real student or guardian id — enforced here
// AND by the DB CHECK constraint (035_phase8_...sql), so this can never be bypassed by a future
// caller that skips this function.
async function recordAgreementAcceptance(pool, { agreementId, acceptedByType, acceptedByStudentId, acceptedByGuardianId, registrationId, enrollmentId, ipAddress }) {
const { rows: defRows } = await pool.query(`SELECT * FROM agreement_definitions WHERE agreement_id = $1`, [agreementId]);
const def = defRows[0];
if (!def) throw err('AGREEMENT_NOT_FOUND', 'No such agreement.');
if (!acceptedByStudentId && !acceptedByGuardianId) throw err('VALIDATION', 'An acceptance must be tied to a student or a guardian.');
if (!['student', 'guardian', 'instructor_on_behalf', 'registrant'].includes(acceptedByType)) {
throw err('VALIDATION', "accepted_by_type must be one of: student, guardian, instructor_on_behalf, registrant.");
}
if (acceptedByType === 'registrant') {
if (!registrationId) throw err('VALIDATION', 'A registrant acceptance must be tied to a registrationId.');
} else if (!acceptedByStudentId && !acceptedByGuardianId) {
throw err('VALIDATION', 'An acceptance must be tied to a student or a guardian.');
}
const { rows } = await pool.query(
`INSERT INTO agreement_acceptances (agreement_id, accepted_version, accepted_by_type, accepted_by_student_id, accepted_by_guardian_id, registration_id, enrollment_id, ip_address)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
@@ -296,6 +412,99 @@ async function listAcceptancesForRegistration(pool, registrationId) {
return rows;
}
// ── Website integration — idempotent registration submission (Step 13/14) ──────────────────
// Mirrors enrollment.js's createFromIntegrationEvent exactly: a real DB-level unique index
// (external_reference, 035_phase8_...sql) is what makes concurrent duplicate submissions safe,
// not a SELECT-then-INSERT check. A replay of the same event_id returns the ORIGINAL
// registration, created:false, never a duplicate — same contract shape as the existing
// /api/integrations/enrollments endpoint website code already knows how to handle.
async function createRegistrationFromIntegrationEvent(pool, { eventId, offeringSlug, registrantDisplayName, registrantEmail, isMinor, guardianDisplayName, guardianEmail, guardianPhone, readinessNotes, source }) {
if (!eventId) throw err('EVENT_ID_REQUIRED', 'event_id is required for idempotent registration events.');
if (!offeringSlug) throw err('OFFERING_SLUG_REQUIRED', 'offering_slug is required.');
if (!registrantDisplayName) throw err('VALIDATION', 'registrant display name is required.');
if (isMinor && !guardianEmail) throw err('GUARDIAN_REQUIRED', 'A minor registration requires a guardian email.');
const existing = await pool.query(`SELECT * FROM offering_registrations WHERE external_reference = $1`, [eventId]);
if (existing.rows[0]) return { registration: existing.rows[0], created: false };
const { rows: offeringRows } = await pool.query(`SELECT * FROM offerings WHERE slug = $1`, [offeringSlug]);
const offering = offeringRows[0];
if (!offering) throw err('OFFERING_NOT_FOUND', 'Unknown offering_slug.');
assertRegistrationOpen(offering);
let guardianId = null;
if (isMinor) {
// Match an existing guardian by email first (a returning family shouldn't get a duplicate
// guardian row per sibling/per camp) — same "match before create" principle
// enrollment.js's resolveOrCreateStudent already established for students.
const existingGuardian = await pool.query(`SELECT guardian_id FROM guardians WHERE email = $1`, [String(guardianEmail).toLowerCase().trim()]);
if (existingGuardian.rows[0]) {
guardianId = existingGuardian.rows[0].guardian_id;
} else {
guardianId = `guardian-${eventId}`;
await createGuardian(pool, { guardianId, displayName: guardianDisplayName || guardianEmail, email: guardianEmail, phone: guardianPhone });
}
}
try {
await reserveSeat(pool, offering.offering_id);
} catch (e) {
if (e.code === 'OFFERING_FULL') throw e;
throw e;
}
try {
const { rows } = await pool.query(
`INSERT INTO offering_registrations (offering_id, guardian_id, registrant_display_name, registrant_email, is_minor, readiness_notes, source, external_reference)
VALUES ($1,$2,$3,$4,COALESCE($5,false),$6,$7,$8)
ON CONFLICT (external_reference) WHERE external_reference IS NOT NULL DO NOTHING
RETURNING *`,
[offering.offering_id, guardianId, registrantDisplayName, registrantEmail || null, isMinor ?? null, readinessNotes || null, source || 'website', eventId]
);
if (rows[0]) {
await notificationsLib.enqueueForRegistration(pool, {
registration: rows[0], notificationType: 'registration_received',
metadata: { display_name: rows[0].registrant_display_name, offering_name: offering.name },
}).catch((e) => console.error("[offerings] website registration_received notification failed:", e.message));
return { registration: rows[0], created: true };
}
// Lost the ON CONFLICT race (concurrent identical replay) — release the seat we just
// reserved (the winner already holds theirs) and return the winner's row.
await releaseSeat(pool, offering.offering_id);
const { rows: won } = await pool.query(`SELECT * FROM offering_registrations WHERE external_reference = $1`, [eventId]);
return { registration: won[0], created: false };
} catch (e) {
await releaseSeat(pool, offering.offering_id).catch(() => {});
throw e;
}
}
// ── Launch readiness (Step 20) — server-derived, never left to frontend logic alone ─────────
async function computeOfferingReadiness(pool, offeringId) {
const offering = await getOffering(pool, offeringId);
if (!offering) throw err('NOT_FOUND', 'No such offering.');
const checks = [];
checks.push({ key: 'curriculum', label: 'Curriculum', ready: !!offering.program_id });
const needsInstructor = offering.delivery_mode !== 'self_paced';
if (needsInstructor) {
const { rows } = await pool.query(`SELECT count(*)::int AS n FROM instructor_offerings WHERE offering_id = $1`, [offeringId]);
checks.push({ key: 'instructor', label: 'Instructor assigned', ready: rows[0].n > 0 });
}
const needsSchedule = !['self_paced'].includes(offering.delivery_mode);
if (needsSchedule) checks.push({ key: 'schedule', label: 'Dates', ready: !!(offering.starts_at && offering.ends_at) });
const needsLocation = !['self_paced'].includes(offering.delivery_mode);
if (needsLocation) checks.push({ key: 'location', label: 'Location', ready: !!offering.location_id || offering.delivery_mode === 'community' });
checks.push({ key: 'capacity', label: 'Capacity', ready: offering.capacity !== null || offering.delivery_mode === 'self_paced' });
checks.push({ key: 'registration_window', label: 'Registration window', ready: !!(offering.enrollment_opens_at && offering.enrollment_closes_at) });
const requirements = await listAgreementRequirementsForOffering(pool, offeringId);
checks.push({ key: 'agreements', label: 'Agreements configured', ready: requirements.length > 0 });
if (offering.curriculum_package_id) {
const pkg = await getCurriculumPackage(pool, offering.curriculum_package_id);
checks.push({ key: 'curriculum_package', label: 'Curriculum package valid', ready: !!pkg && pkg.items.length > 0 });
}
const allReady = checks.every((c) => c.ready);
return { offering_id: offeringId, ready_to_open_registration: allReady, checks };
}
module.exports = {
createOrganization, listOrganizations,
createLocation, listLocations,
@@ -303,5 +512,7 @@ module.exports = {
createCurriculumPackage, addCurriculumPackageItem, getCurriculumPackage,
createOffering, getOffering, listOfferings, transitionOffering, assignInstructorToOffering, reserveSeat, releaseSeat,
createRegistration, getRegistration, listRegistrationsForOffering, transitionRegistration, convertRegistrationToEnrollment,
createAgreementDefinition, recordAgreementAcceptance, listAcceptancesForRegistration,
createAgreementDefinition, listAgreementDefinitions, recordAgreementAcceptance, listAcceptancesForRegistration,
addAgreementRequirement, listAgreementRequirementsForOffering, getRegistrationAgreementStatus,
createRegistrationFromIntegrationEvent, computeOfferingReadiness, assertRegistrationOpen,
};