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