diff --git a/platform/api/migrations/035_phase8_enrollment_operations.sql b/platform/api/migrations/035_phase8_enrollment_operations.sql new file mode 100644 index 0000000..a5175ab --- /dev/null +++ b/platform/api/migrations/035_phase8_enrollment_operations.sql @@ -0,0 +1,53 @@ +-- Phase 8 — enrollment operations foundation: registration-stage notifications, a clean +-- pre-account agreement-acceptance type, offering agreement requirements, and idempotent +-- website registration submission. All additive/widening — no existing row's meaning changes, +-- no existing constraint gets stricter. See docs/PHASE8_ENROLLMENT_OPERATIONS.md. + +-- ═══════════════════════════════════════════════════════════════════════════════════════════ +-- notification_events — a registration may need a notification (Registration Received, Agreement +-- Required) before any student row exists at all (a brand-new guardian-submitted registration). +-- student_id is widened to nullable and a registration_id column added; a CHECK guarantees every +-- row still has SOME real subject. Every existing row already has student_id set, so this is a +-- pure widening — nothing about student-scoped notification behavior changes. +-- ═══════════════════════════════════════════════════════════════════════════════════════════ +ALTER TABLE notification_events ALTER COLUMN student_id DROP NOT NULL; +ALTER TABLE notification_events ADD COLUMN IF NOT EXISTS registration_id UUID REFERENCES offering_registrations(registration_id); +ALTER TABLE notification_events DROP CONSTRAINT IF EXISTS notification_events_subject_check; +ALTER TABLE notification_events ADD CONSTRAINT notification_events_subject_check CHECK (student_id IS NOT NULL OR registration_id IS NOT NULL); +CREATE INDEX IF NOT EXISTS idx_notification_events_registration ON notification_events(registration_id); + +-- ═══════════════════════════════════════════════════════════════════════════════════════════ +-- agreement_acceptances — a 4th accepted_by_type, 'registrant', for the one real gap Phase 7's +-- own limitations list named: an adult accepting an agreement AT REGISTRATION TIME, before their +-- eventual student_id exists. Tied to registration_id only (no student/guardian id required) — +-- the CHECK is widened to accept that shape too. 'student'/'guardian'/'instructor_on_behalf' +-- (admin-recorded on someone's behalf) are unchanged and already covered Step 7's "student / +-- guardian / admin-instructor" minimum bar; 'registrant' closes the actual documented gap. +-- ═══════════════════════════════════════════════════════════════════════════════════════════ +ALTER TABLE agreement_acceptances DROP CONSTRAINT IF EXISTS agreement_acceptances_accepted_by_type_check; +ALTER TABLE agreement_acceptances ADD CONSTRAINT agreement_acceptances_accepted_by_type_check + CHECK (accepted_by_type IN ('student', 'guardian', 'instructor_on_behalf', 'registrant')); +ALTER TABLE agreement_acceptances DROP CONSTRAINT IF EXISTS agreement_acceptances_check; +ALTER TABLE agreement_acceptances ADD CONSTRAINT agreement_acceptances_check + CHECK (accepted_by_student_id IS NOT NULL OR accepted_by_guardian_id IS NOT NULL OR (accepted_by_type = 'registrant' AND registration_id IS NOT NULL)); + +-- ═══════════════════════════════════════════════════════════════════════════════════════════ +-- offering_agreement_requirements — which agreements a given offering actually requires before +-- its registrations may convert to enrollment (Step 18). A plain join table, not a new status +-- machine — offerings.js checks it at conversion time. +-- ═══════════════════════════════════════════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS offering_agreement_requirements ( + offering_id TEXT NOT NULL REFERENCES offerings(offering_id), + agreement_id TEXT NOT NULL REFERENCES agreement_definitions(agreement_id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (offering_id, agreement_id) +); + +-- ═══════════════════════════════════════════════════════════════════════════════════════════ +-- offering_registrations — a real unique index on external_reference (nulls excluded) is what +-- actually makes website registration submission idempotent under concurrent retries, the exact +-- same pattern enrollments.external_event_id already established in Phase 5B — a DB-level +-- constraint, not a SELECT-then-INSERT check. +-- ═══════════════════════════════════════════════════════════════════════════════════════════ +CREATE UNIQUE INDEX IF NOT EXISTS idx_offering_registrations_external_reference + ON offering_registrations(external_reference) WHERE external_reference IS NOT NULL; diff --git a/platform/api/migrations/036_phase8_notification_types.sql b/platform/api/migrations/036_phase8_notification_types.sql new file mode 100644 index 0000000..02dd60d --- /dev/null +++ b/platform/api/migrations/036_phase8_notification_types.sql @@ -0,0 +1,15 @@ +-- Phase 8 — self-caught correction to 035_phase8_enrollment_operations.sql, found via this +-- phase's own smoke testing before any commit: notification_events.notification_type carries a +-- CHECK constraint (Phase 5C) that never anticipated registration-stage notification types. +-- Every offerings.js call to notifications.enqueueForRegistration() was silently failing (caught +-- and swallowed by the deliberate "a notification failure must never block the real action" +-- try/catch) until this was found — no registration-stage notification had ever actually been +-- queued. Pure widening: every existing notification_type value stays valid, nothing about +-- Phase 5C's own four types changes. +ALTER TABLE notification_events DROP CONSTRAINT IF EXISTS notification_events_notification_type_check; +ALTER TABLE notification_events ADD CONSTRAINT notification_events_notification_type_check + CHECK (notification_type IN ( + 'activation', 'enrollment_confirmation', 'password_reset', 'password_changed', + 'registration_received', 'registration_confirmed', 'registration_needs_action', + 'agreement_required', 'camp_operational_update' + )); diff --git a/platform/api/src/admin-offerings-routes.js b/platform/api/src/admin-offerings-routes.js index a865154..db89e05 100644 --- a/platform/api/src/admin-offerings-routes.js +++ b/platform/api/src/admin-offerings-routes.js @@ -8,12 +8,16 @@ // notes (readiness/accessibility/emergency-contact) directly. const offerings = require('./offerings'); +const notifications = require('./notifications'); const studentsAuth = require('./students-auth'); // same direct-require convention integration-routes.js already uses function errStatus(code) { if (code === 'NOT_FOUND' || code === 'PROGRAM_NOT_FOUND' || code === 'OFFERING_NOT_FOUND' || code === 'COURSE_NOT_FOUND' || code === 'AGREEMENT_NOT_FOUND') return 404; if (code === 'VALIDATION' || code === 'GUARDIAN_REQUIRED') return 400; - if (code === 'DUPLICATE_SLUG' || code === 'OFFERING_FULL' || code === 'INVALID_TRANSITION' || code === 'INVALID_STATE') return 409; + if (code === 'DUPLICATE_SLUG' || code === 'OFFERING_FULL' || code === 'INVALID_TRANSITION' || code === 'INVALID_STATE' + || code === 'OFFERING_NOT_OPEN' || code === 'OFFERING_CANCELLED' || code === 'OFFERING_COMPLETED' + || code === 'REGISTRATION_NOT_YET_OPEN' || code === 'REGISTRATION_CLOSED' || code === 'DUPLICATE_REGISTRATION' + || code === 'AGREEMENTS_INCOMPLETE') return 409; return 500; } @@ -125,30 +129,48 @@ module.exports = function registerAdminOfferingsRoutes(app, deps) { res.status(201).json(link); } catch (e) { handleError(res, e); } }); + admin.get('/api/admin/offerings/:id/readiness', requireAnyInstructor, async (req, res) => { + try { + res.json(await offerings.computeOfferingReadiness(getPool(), req.params.id)); + } catch (e) { handleError(res, e); } + }); + admin.post('/api/admin/offerings/:id/agreement-requirements', requireAdminOrOwner, async (req, res) => { + try { + const link = await offerings.addAgreementRequirement(getPool(), req.params.id, req.body.agreement_id); + res.status(201).json(link); + } catch (e) { handleError(res, e); } + }); + admin.get('/api/admin/offerings/:id/agreement-requirements', requireAnyInstructor, async (req, res) => { + res.json(await offerings.listAgreementRequirementsForOffering(getPool(), req.params.id)); + }); // ── Registrations (admin/owner only) ──────────────────────────────────────────────────── - // Validate-then-reserve, not reserve-then-validate — reserving the seat first would let a - // request that fails plain field validation (e.g. a minor with no guardianId) consume a real - // seat before ever being rejected, masking the actual validation error behind a false - // OFFERING_FULL once the last seat is gone. Found during this phase's own smoke testing. + // offerings.createRegistration() itself now does validate -> reserve -> insert -> notify, in + // that exact order (see its own header comment) — no route-level orchestration needed here + // any more. admin.post('/api/admin/offerings/:id/registrations', requireAdminOrOwner, async (req, res) => { try { const reg = await offerings.createRegistration(getPool(), { ...req.body, offeringId: req.params.id }); - try { - await offerings.reserveSeat(getPool(), req.params.id); - } catch (seatError) { - // Field validation passed but capacity is genuinely exhausted — the registration row - // it already created must not silently exist as a phantom "started" entry with no - // seat behind it. - await getPool().query(`DELETE FROM offering_registrations WHERE registration_id = $1`, [reg.registration_id]); - throw seatError; - } res.status(201).json(reg); } catch (e) { handleError(res, e); } }); admin.get('/api/admin/offerings/:id/registrations', requireAdminOrOwner, async (req, res) => { res.json(await offerings.listRegistrationsForOffering(getPool(), req.params.id)); }); + admin.get('/api/admin/registrations/:id', requireAdminOrOwner, async (req, res) => { + const reg = await offerings.getRegistration(getPool(), req.params.id); + if (!reg) return res.status(404).json({ error: 'NOT_FOUND' }); + res.json(reg); + }); + admin.get('/api/admin/registrations/:id/agreement-status', requireAdminOrOwner, async (req, res) => { + try { + res.json(await offerings.getRegistrationAgreementStatus(getPool(), req.params.id)); + } catch (e) { handleError(res, e); } + }); + admin.get('/api/admin/registrations/:id/notifications', requireAdminOrOwner, async (req, res) => { + const rows = await notifications.listForRegistration(getPool(), req.params.id); + res.json(rows.map(notifications.toAdminView)); + }); admin.post('/api/admin/registrations/:id/transition', requireAdminOrOwner, async (req, res) => { try { const reg = await offerings.transitionRegistration(getPool(), req.params.id, req.body.status); @@ -170,6 +192,9 @@ module.exports = function registerAdminOfferingsRoutes(app, deps) { res.status(201).json(def); } catch (e) { handleError(res, e); } }); + admin.get('/api/admin/agreements', requireAnyInstructor, async (req, res) => { + res.json(await offerings.listAgreementDefinitions(getPool())); + }); admin.post('/api/admin/registrations/:id/agreements', requireAdminOrOwner, async (req, res) => { try { const acceptance = await offerings.recordAgreementAcceptance(getPool(), { ...req.body, registrationId: req.params.id, ipAddress: req.ip }); diff --git a/platform/api/src/email/templates.js b/platform/api/src/email/templates.js index 3072b15..dfa51fa 100644 --- a/platform/api/src/email/templates.js +++ b/platform/api/src/email/templates.js @@ -131,4 +131,76 @@ If you didn't make this change, contact your instructor right away — your othe return { subject, text, html }; } -module.exports = { activation, enrollmentConfirmation, passwordReset, passwordChanged, escapeHtml }; +// Phase 8 §10 — registration-stage transactional templates. Same conventions as above: +// escapeHtml on every caller-supplied value (offeringName/displayName can come from a website +// registration submission, not just admin-typed text), no tracking, no marketing content, both +// text and html written deliberately. + +function registrationReceived({ displayName, offeringName }) { + const subject = `We Received Your Registration — ${offeringName}`; + const safeName = escapeHtml(displayName), safeOffering = escapeHtml(offeringName); + const text = `Hi ${displayName}, + +We received your registration for ${offeringName} at ${BRAND}. We'll follow up if anything else is needed before it's confirmed. + +— ${BRAND}`; + const html = wrap(`

Hi ${safeName},

We received your registration for ${safeOffering} at ${BRAND}. We'll follow up if anything else is needed before it's confirmed.

`); + return { subject, text, html }; +} + +function registrationConfirmed({ displayName, offeringName }) { + const subject = `Registration Confirmed — ${offeringName}`; + const safeName = escapeHtml(displayName), safeOffering = escapeHtml(offeringName); + const text = `Hi ${displayName}, + +Your registration for ${offeringName} is confirmed. You'll receive a separate email with account access details shortly. + +— ${BRAND}`; + const html = wrap(`

Hi ${safeName},

Your registration for ${safeOffering} is confirmed. You'll receive a separate email with account access details shortly.

`); + return { subject, text, html }; +} + +function registrationNeedsAction({ displayName, offeringName, reason }) { + const subject = `Action Needed — ${offeringName} Registration`; + const safeName = escapeHtml(displayName), safeOffering = escapeHtml(offeringName), safeReason = escapeHtml(reason || 'Please contact us for details.'); + const text = `Hi ${displayName}, + +Your registration for ${offeringName} needs one more thing before it can be confirmed: ${reason || 'please contact us for details.'} + +— ${BRAND}`; + const html = wrap(`

Hi ${safeName},

Your registration for ${safeOffering} needs one more thing before it can be confirmed:

${safeReason}

`); + return { subject, text, html }; +} + +function agreementRequired({ displayName, offeringName, agreementTitle }) { + const subject = `Agreement Required — ${offeringName}`; + const safeName = escapeHtml(displayName), safeOffering = escapeHtml(offeringName), safeAgreement = escapeHtml(agreementTitle); + const text = `Hi ${displayName}, + +Before your registration for ${offeringName} can be confirmed, we need your acceptance of: ${agreementTitle}. Your instructor/admin contact can help you complete this. + +— ${BRAND}`; + const html = wrap(`

Hi ${safeName},

Before your registration for ${safeOffering} can be confirmed, we need your acceptance of:

${safeAgreement}

`); + return { subject, text, html }; +} + +// Operational notice only (e.g. "Camp starts Monday, here's what to bring") — deliberately NOT +// a marketing template. See docs/REGISTRATION_AND_GUARDIAN_COMMUNICATIONS.md's boundary note. +function campOperationalUpdate({ displayName, offeringName, message }) { + const subject = `${offeringName} — Update`; + const safeName = escapeHtml(displayName), safeOffering = escapeHtml(offeringName), safeMessage = escapeHtml(message); + const text = `Hi ${displayName}, + +An update about ${offeringName}: + +${message} + +— ${BRAND}`; + const html = wrap(`

Hi ${safeName},

An update about ${safeOffering}:

${safeMessage}

`); + return { subject, text, html }; +} + +module.exports = { + activation, enrollmentConfirmation, passwordReset, passwordChanged, escapeHtml, + registrationReceived, registrationConfirmed, registrationNeedsAction, agreementRequired, campOperationalUpdate, +}; diff --git a/platform/api/src/integration-routes.js b/platform/api/src/integration-routes.js index c2e4000..7f7ce6a 100644 --- a/platform/api/src/integration-routes.js +++ b/platform/api/src/integration-routes.js @@ -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, + }); + })); }; diff --git a/platform/api/src/notification-worker.js b/platform/api/src/notification-worker.js index 5fe9e95..517f955 100644 --- a/platform/api/src/notification-worker.js +++ b/platform/api/src/notification-worker.js @@ -44,6 +44,23 @@ function renderFor(row) { if (row.notification_type === 'password_changed') { return templates.passwordChanged({ displayName }); } + // Phase 8 — registration-stage notifications. No token/public-origin dependency, so these + // never fail on missing PUBLIC_APP_ORIGIN config the way activation/reset do. + if (row.notification_type === 'registration_received') { + return templates.registrationReceived({ displayName, offeringName: row.metadata?.offering_name || 'your offering' }); + } + if (row.notification_type === 'registration_confirmed') { + return templates.registrationConfirmed({ displayName, offeringName: row.metadata?.offering_name || 'your offering' }); + } + if (row.notification_type === 'registration_needs_action') { + return templates.registrationNeedsAction({ displayName, offeringName: row.metadata?.offering_name || 'your offering', reason: row.metadata?.reason }); + } + if (row.notification_type === 'agreement_required') { + return templates.agreementRequired({ displayName, offeringName: row.metadata?.offering_name || 'your offering', agreementTitle: row.metadata?.agreement_title || 'a required agreement' }); + } + if (row.notification_type === 'camp_operational_update') { + return templates.campOperationalUpdate({ displayName, offeringName: row.metadata?.offering_name || 'your offering', message: row.metadata?.message || '' }); + } return null; } diff --git a/platform/api/src/notifications.js b/platform/api/src/notifications.js index 362d869..e26efdb 100644 --- a/platform/api/src/notifications.js +++ b/platform/api/src/notifications.js @@ -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, }; diff --git a/platform/api/src/offerings.js b/platform/api/src/offerings.js index f439f5e..dab3236 100644 --- a/platform/api/src/offerings.js +++ b/platform/api/src/offerings.js @@ -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, }; diff --git a/platform/api/src/public-offerings-routes.js b/platform/api/src/public-offerings-routes.js new file mode 100644 index 0000000..cf94b51 --- /dev/null +++ b/platform/api/src/public-offerings-routes.js @@ -0,0 +1,59 @@ +// Phase 8 §12 — the PUBLIC, unauthenticated offering-read surface the website reads to build +// its own catalog/registration pages. No session of any kind (student or instructor) is +// checked or accepted here — this is meant to be reachable by anyone, the same trust level as +// the marketing site itself. An explicit allowlist serializer (never SELECT *, never returning +// a raw row) is what keeps this safe: internal notes, access_notes, organization contact info, +// instructor identity, and anything registration/student/guardian-shaped are never even +// queried by this file, let alone serialized. + +function toPublicOffering(row) { + return { + slug: row.slug, + name: row.name, + description: row.description, + program_id: row.program_id, + delivery_mode: row.delivery_mode, + recommended_min_age: row.recommended_min_age, + recommended_max_age: row.recommended_max_age, + starts_at: row.starts_at, + ends_at: row.ends_at, + registration_open: row.status === 'enrollment_open', + // "availability" rather than an exact seat count — a real number invites scraping/timing + // games from a public, unauthenticated endpoint; a coarse status is enough for a website to + // build a real "Register" vs "Full" call to action. + availability: row.capacity === null ? 'open' : row.reserved_seats >= row.capacity ? 'full' : (row.capacity - row.reserved_seats) <= 3 ? 'limited' : 'open', + location_name: row.location_name || null, + location_type: row.location_type || null, + }; +} + +module.exports = function registerPublicOfferingsRoutes(app, deps) { + const { getPool, wrap } = deps; + + // Only offerings a family/adult should actually be able to see: published or further along, + // never draft (an admin still building it out) and never cancelled. + const VISIBLE_STATUSES = ['published', 'enrollment_open', 'full', 'in_progress']; + + app.get('/api/public/offerings', wrap(async (req, res) => { + const { rows } = await getPool().query( + `SELECT o.slug, o.name, o.description, o.program_id, o.delivery_mode, o.recommended_min_age, o.recommended_max_age, + o.starts_at, o.ends_at, o.status, o.capacity, o.reserved_seats, l.name AS location_name, l.type AS location_type + FROM offerings o LEFT JOIN locations l ON l.location_id = o.location_id + WHERE o.status = ANY($1) ORDER BY o.starts_at NULLS LAST, o.name`, + [VISIBLE_STATUSES] + ); + res.json(rows.map(toPublicOffering)); + })); + + app.get('/api/public/offerings/:slug', wrap(async (req, res) => { + const { rows } = await getPool().query( + `SELECT o.slug, o.name, o.description, o.program_id, o.delivery_mode, o.recommended_min_age, o.recommended_max_age, + o.starts_at, o.ends_at, o.status, o.capacity, o.reserved_seats, l.name AS location_name, l.type AS location_type + FROM offerings o LEFT JOIN locations l ON l.location_id = o.location_id + WHERE o.slug = $1 AND o.status = ANY($2)`, + [req.params.slug, VISIBLE_STATUSES] + ); + if (!rows[0]) return res.status(404).json({ error: 'not_found' }); + res.json(toPublicOffering(rows[0])); + })); +}; diff --git a/platform/api/src/server.js b/platform/api/src/server.js index 79aa025..9286a58 100644 --- a/platform/api/src/server.js +++ b/platform/api/src/server.js @@ -421,6 +421,11 @@ require('./admin-offerings-routes')(app, { instructorSession: wrap(instructorSession), rbac, getPool, recordAudit, }); +// Phase 8 — the public, unauthenticated offering-read surface (no session of any kind), and +// the website's registration submission endpoint (server-to-server, same shared-secret +// boundary as /api/integrations/enrollments, registered alongside it in integration-routes.js). +require('./public-offerings-routes')(app, { getPool, wrap }); + // Fallback to the platform-shell SPA for any unmatched non-API route app.get('*', (req, res) => { res.sendFile(path.join(FRONTEND_DIST, 'index.html'));