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
+48
View File
@@ -16,6 +16,7 @@ const enrollment = require('./enrollment');
const studentsAuth = require('./students-auth');
const notifications = require('./notifications');
const programsLib = require('./programs');
const offerings = require('./offerings');
const metrics = require('./metrics');
const INTEGRATION_SHARED_SECRET = process.env.INTEGRATION_SHARED_SECRET;
@@ -36,6 +37,17 @@ function enrollmentErrorStatus(code) {
return 500;
}
// Phase 8 §13 — website registration submission. Same auth boundary and same file as
// /api/integrations/enrollments above (Step 15's "prefer server-to-server, don't introduce a
// second scheme") — a registration is an earlier, separate step from an enrollment (see
// docs/OFFERING_AND_REGISTRATION_MODEL.md), not a replacement for the existing endpoint.
function registrationErrorStatus(code) {
if (code === 'OFFERING_NOT_FOUND') return 404;
if (code === 'EVENT_ID_REQUIRED' || code === 'OFFERING_SLUG_REQUIRED' || code === 'VALIDATION' || code === 'GUARDIAN_REQUIRED') return 400;
if (['OFFERING_NOT_OPEN', 'OFFERING_CANCELLED', 'OFFERING_COMPLETED', 'OFFERING_FULL', 'REGISTRATION_NOT_YET_OPEN', 'REGISTRATION_CLOSED'].includes(code)) return 409;
return 500;
}
module.exports = function registerIntegrationRoutes(app, deps) {
const { getPool, recordAudit, wrap } = deps;
@@ -114,4 +126,40 @@ module.exports = function registerIntegrationRoutes(app, deps) {
created: result.created,
});
}));
// Phase 8 §13/§14/§15 — the website's registration submission endpoint. Same shared-secret
// header as above, same "server-to-server only, never browser JS" boundary. Idempotent via
// event_id -> offering_registrations.external_reference (a real unique index,
// 035_phase8_...sql), the identical pattern /api/integrations/enrollments already uses.
app.post('/api/integrations/registrations', wrap(async (req, res) => {
const key = req.get('X-RootNode-Integration-Key');
if (!INTEGRATION_SHARED_SECRET || !key || key !== INTEGRATION_SHARED_SECRET) {
return res.status(401).json({ error: 'unauthorized' });
}
const { event_id, source, offering_slug, registrant, guardian, readiness_notes } = req.body || {};
let result;
try {
result = await offerings.createRegistrationFromIntegrationEvent(getPool(), {
eventId: event_id, offeringSlug: offering_slug, source: source || 'website',
registrantDisplayName: registrant?.display_name, registrantEmail: registrant?.email, isMinor: !!registrant?.is_minor,
guardianDisplayName: guardian?.display_name, guardianEmail: guardian?.email, guardianPhone: guardian?.phone,
readinessNotes: readiness_notes,
});
} catch (e) {
if (e.code) return res.status(registrationErrorStatus(e.code)).json({ error: e.code, message: e.message });
throw e;
}
await recordAudit(getPool(), {
actorType: 'integration', actorId: source || 'website',
action: result.created ? 'registration_integration_created' : 'registration_integration_duplicate',
resourceType: 'registration', resourceId: result.registration.registration_id, result: 'success', sourceIp: req.ip,
metadata: { event_id, offering_slug },
});
res.status(result.created ? 201 : 200).json({
registration_id: result.registration.registration_id,
offering_id: result.registration.offering_id,
status: result.registration.status,
created: result.created,
});
}));
};