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:
@@ -38,9 +38,57 @@ async function resolveRecipient(pool, studentId, enrollmentId) {
|
||||
const contact = enrollmentContactEmail(enrollmentRows[0]);
|
||||
if (contact) return contact;
|
||||
}
|
||||
// Phase 8 §9 — prefer the real guardian model (Phase 7) over nothing: a no-email student with
|
||||
// no enrollment-level contact_email but a real, communications-enabled primary guardian still
|
||||
// gets notified. Tried last, after both of the above, so an existing student.email or the
|
||||
// established enrollment.metadata.contact_email always wins first — this is purely a new
|
||||
// fallback, never a behavior change for any student who already resolves correctly today.
|
||||
const { rows: guardianRows } = await pool.query(
|
||||
`SELECT g.email FROM student_guardians sg JOIN guardians g ON g.guardian_id = sg.guardian_id
|
||||
WHERE sg.student_id = $1 AND sg.can_receive_communications = true AND g.email IS NOT NULL
|
||||
ORDER BY sg.is_primary_contact DESC LIMIT 1`,
|
||||
[studentId]
|
||||
);
|
||||
if (guardianRows[0]?.email) return guardianRows[0].email;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Phase 8 — the registration-stage counterpart to enqueue() above, for a notification that may
|
||||
// need to go out BEFORE any student row exists at all (a brand-new guardian-submitted
|
||||
// registration). Recipient resolution order: the registration's own student (if already
|
||||
// converted — reuses resolveRecipient/the guardian fallback above), else its linked guardian,
|
||||
// else the registrant's own supplied email. Never creates a second notification pipeline —
|
||||
// same notification_events table, same worker, same templates module.
|
||||
async function resolveRegistrationRecipient(pool, registration) {
|
||||
if (registration.student_id) {
|
||||
const viaStudent = await resolveRecipient(pool, registration.student_id, registration.enrollment_id);
|
||||
if (viaStudent) return viaStudent;
|
||||
}
|
||||
if (registration.guardian_id) {
|
||||
const { rows } = await pool.query(`SELECT email FROM guardians WHERE guardian_id = $1`, [registration.guardian_id]);
|
||||
if (rows[0]?.email) return rows[0].email;
|
||||
}
|
||||
return registration.registrant_email || null;
|
||||
}
|
||||
|
||||
async function enqueueForRegistration(pool, { registration, notificationType, metadata }) {
|
||||
const recipient = await resolveRegistrationRecipient(pool, registration);
|
||||
if (!recipient) {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO notification_events (student_id, registration_id, notification_type, recipient_email, status, last_error_code, failed_at, metadata)
|
||||
VALUES ($1, $2, $3, NULL, 'failed', 'no_recipient_address', now(), $4) RETURNING *`,
|
||||
[registration.student_id || null, registration.registration_id, notificationType, JSON.stringify({})]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO notification_events (student_id, registration_id, notification_type, recipient_email, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
||||
[registration.student_id || null, registration.registration_id, notificationType, recipient, JSON.stringify(metadata || {})]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
// Inserts a queued row. plaintextToken is optional (activation/password_reset only) and, if
|
||||
// given, is the ONLY place its value is persisted anywhere — purged on the row's terminal
|
||||
// state. A notification with no resolvable recipient is still recorded (never silently
|
||||
@@ -145,6 +193,12 @@ async function listForStudent(pool, studentId) {
|
||||
const { rows } = await pool.query(`SELECT * FROM notification_events WHERE student_id = $1 ORDER BY created_at DESC`, [studentId]);
|
||||
return rows;
|
||||
}
|
||||
// Phase 8 — every notification tied to a registration, including pre-conversion ones with no
|
||||
// student_id yet at all.
|
||||
async function listForRegistration(pool, registrationId) {
|
||||
const { rows } = await pool.query(`SELECT * FROM notification_events WHERE registration_id = $1 ORDER BY created_at DESC`, [registrationId]);
|
||||
return rows;
|
||||
}
|
||||
async function getById(pool, notificationId) {
|
||||
const { rows } = await pool.query(`SELECT * FROM notification_events WHERE notification_id = $1`, [notificationId]);
|
||||
return rows[0] || null;
|
||||
@@ -177,6 +231,7 @@ function maskEmail(email) {
|
||||
function toAdminView(row) {
|
||||
return {
|
||||
notification_id: row.notification_id, student_id: row.student_id, enrollment_id: row.enrollment_id,
|
||||
registration_id: row.registration_id,
|
||||
notification_type: row.notification_type, channel: row.channel, recipient_email_masked: maskEmail(row.recipient_email),
|
||||
provider: row.provider, status: row.status, attempt_count: row.attempt_count, last_error_code: row.last_error_code,
|
||||
created_at: row.created_at, sent_at: row.sent_at, failed_at: row.failed_at,
|
||||
@@ -186,4 +241,5 @@ function toAdminView(row) {
|
||||
module.exports = {
|
||||
resolveRecipient, enqueue, claimNext, markSent, markFailedOrRetry, notifyForEnrollment,
|
||||
listForStudent, getById, getDevPreview, maskEmail, toAdminView, MAX_ATTEMPTS,
|
||||
enqueueForRegistration, listForRegistration,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user