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:
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user