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
+56
View File
@@ -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,
};