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
@@ -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;
@@ -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'
));
+39 -14
View File
@@ -8,12 +8,16 @@
// notes (readiness/accessibility/emergency-contact) directly. // notes (readiness/accessibility/emergency-contact) directly.
const offerings = require('./offerings'); const offerings = require('./offerings');
const notifications = require('./notifications');
const studentsAuth = require('./students-auth'); // same direct-require convention integration-routes.js already uses const studentsAuth = require('./students-auth'); // same direct-require convention integration-routes.js already uses
function errStatus(code) { 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 === '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 === '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; return 500;
} }
@@ -125,30 +129,48 @@ module.exports = function registerAdminOfferingsRoutes(app, deps) {
res.status(201).json(link); res.status(201).json(link);
} catch (e) { handleError(res, e); } } 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) ──────────────────────────────────────────────────── // ── Registrations (admin/owner only) ────────────────────────────────────────────────────
// Validate-then-reserve, not reserve-then-validate — reserving the seat first would let a // offerings.createRegistration() itself now does validate -> reserve -> insert -> notify, in
// request that fails plain field validation (e.g. a minor with no guardianId) consume a real // that exact order (see its own header comment) — no route-level orchestration needed here
// seat before ever being rejected, masking the actual validation error behind a false // any more.
// OFFERING_FULL once the last seat is gone. Found during this phase's own smoke testing.
admin.post('/api/admin/offerings/:id/registrations', requireAdminOrOwner, async (req, res) => { admin.post('/api/admin/offerings/:id/registrations', requireAdminOrOwner, async (req, res) => {
try { try {
const reg = await offerings.createRegistration(getPool(), { ...req.body, offeringId: req.params.id }); 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); res.status(201).json(reg);
} catch (e) { handleError(res, e); } } catch (e) { handleError(res, e); }
}); });
admin.get('/api/admin/offerings/:id/registrations', requireAdminOrOwner, async (req, res) => { admin.get('/api/admin/offerings/:id/registrations', requireAdminOrOwner, async (req, res) => {
res.json(await offerings.listRegistrationsForOffering(getPool(), req.params.id)); 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) => { admin.post('/api/admin/registrations/:id/transition', requireAdminOrOwner, async (req, res) => {
try { try {
const reg = await offerings.transitionRegistration(getPool(), req.params.id, req.body.status); 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); res.status(201).json(def);
} catch (e) { handleError(res, e); } } 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) => { admin.post('/api/admin/registrations/:id/agreements', requireAdminOrOwner, async (req, res) => {
try { try {
const acceptance = await offerings.recordAgreementAcceptance(getPool(), { ...req.body, registrationId: req.params.id, ipAddress: req.ip }); const acceptance = await offerings.recordAgreementAcceptance(getPool(), { ...req.body, registrationId: req.params.id, ipAddress: req.ip });
+73 -1
View File
@@ -131,4 +131,76 @@ If you didn't make this change, contact your instructor right away — your othe
return { subject, text, html }; 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(`<p>Hi ${safeName},</p><p>We received your registration for <strong>${safeOffering}</strong> at ${BRAND}. We'll follow up if anything else is needed before it's confirmed.</p>`);
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(`<p>Hi ${safeName},</p><p>Your registration for <strong>${safeOffering}</strong> is confirmed. You'll receive a separate email with account access details shortly.</p>`);
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(`<p>Hi ${safeName},</p><p>Your registration for <strong>${safeOffering}</strong> needs one more thing before it can be confirmed:</p><p>${safeReason}</p>`);
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(`<p>Hi ${safeName},</p><p>Before your registration for <strong>${safeOffering}</strong> can be confirmed, we need your acceptance of:</p><p><strong>${safeAgreement}</strong></p>`);
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(`<p>Hi ${safeName},</p><p>An update about <strong>${safeOffering}</strong>:</p><p>${safeMessage}</p>`);
return { subject, text, html };
}
module.exports = {
activation, enrollmentConfirmation, passwordReset, passwordChanged, escapeHtml,
registrationReceived, registrationConfirmed, registrationNeedsAction, agreementRequired, campOperationalUpdate,
};
+48
View File
@@ -16,6 +16,7 @@ const enrollment = require('./enrollment');
const studentsAuth = require('./students-auth'); const studentsAuth = require('./students-auth');
const notifications = require('./notifications'); const notifications = require('./notifications');
const programsLib = require('./programs'); const programsLib = require('./programs');
const offerings = require('./offerings');
const metrics = require('./metrics'); const metrics = require('./metrics');
const INTEGRATION_SHARED_SECRET = process.env.INTEGRATION_SHARED_SECRET; const INTEGRATION_SHARED_SECRET = process.env.INTEGRATION_SHARED_SECRET;
@@ -36,6 +37,17 @@ function enrollmentErrorStatus(code) {
return 500; 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) { module.exports = function registerIntegrationRoutes(app, deps) {
const { getPool, recordAudit, wrap } = deps; const { getPool, recordAudit, wrap } = deps;
@@ -114,4 +126,40 @@ module.exports = function registerIntegrationRoutes(app, deps) {
created: result.created, 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,
});
}));
}; };
+17
View File
@@ -44,6 +44,23 @@ function renderFor(row) {
if (row.notification_type === 'password_changed') { if (row.notification_type === 'password_changed') {
return templates.passwordChanged({ displayName }); 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; return null;
} }
+56
View File
@@ -38,9 +38,57 @@ async function resolveRecipient(pool, studentId, enrollmentId) {
const contact = enrollmentContactEmail(enrollmentRows[0]); const contact = enrollmentContactEmail(enrollmentRows[0]);
if (contact) return contact; 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; 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 // 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 // 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 // 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]); const { rows } = await pool.query(`SELECT * FROM notification_events WHERE student_id = $1 ORDER BY created_at DESC`, [studentId]);
return rows; 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) { async function getById(pool, notificationId) {
const { rows } = await pool.query(`SELECT * FROM notification_events WHERE notification_id = $1`, [notificationId]); const { rows } = await pool.query(`SELECT * FROM notification_events WHERE notification_id = $1`, [notificationId]);
return rows[0] || null; return rows[0] || null;
@@ -177,6 +231,7 @@ function maskEmail(email) {
function toAdminView(row) { function toAdminView(row) {
return { return {
notification_id: row.notification_id, student_id: row.student_id, enrollment_id: row.enrollment_id, 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), 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, 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, created_at: row.created_at, sent_at: row.sent_at, failed_at: row.failed_at,
@@ -186,4 +241,5 @@ function toAdminView(row) {
module.exports = { module.exports = {
resolveRecipient, enqueue, claimNext, markSent, markFailedOrRetry, notifyForEnrollment, resolveRecipient, enqueue, claimNext, markSent, markFailedOrRetry, notifyForEnrollment,
listForStudent, getById, getDevPreview, maskEmail, toAdminView, MAX_ATTEMPTS, listForStudent, getById, getDevPreview, maskEmail, toAdminView, MAX_ATTEMPTS,
enqueueForRegistration, listForRegistration,
}; };
+220 -9
View File
@@ -6,6 +6,7 @@
// model's own reasoning. // model's own reasoning.
const enrollmentLib = require('./enrollment'); const enrollmentLib = require('./enrollment');
const notificationsLib = require('./notifications');
function err(code, message) { function err(code, message) {
return Object.assign(new Error(message || code), { code }); return Object.assign(new Error(message || code), { code });
@@ -199,17 +200,59 @@ const REGISTRATION_TRANSITIONS = {
cancelled: [], 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 }) { 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 (!offeringId || !registrantDisplayName) throw err('VALIDATION', 'offeringId and registrantDisplayName are required.');
if (isMinor && !guardianId) throw err('GUARDIAN_REQUIRED', 'A minor registration must include a guardianId.'); if (isMinor && !guardianId) throw err('GUARDIAN_REQUIRED', 'A minor registration must include a guardianId.');
const offering = await getOffering(pool, offeringId); const offering = await getOffering(pool, offeringId);
if (!offering) throw err('OFFERING_NOT_FOUND', 'No such offering.'); if (!offering) throw err('OFFERING_NOT_FOUND', 'No such offering.');
const { rows } = await pool.query( assertRegistrationOpen(offering);
`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 *`, await reserveSeat(pool, offeringId); // throws OFFERING_FULL if genuinely exhausted; nothing created yet if so
[offeringId, guardianId || null, registrantDisplayName, registrantEmail || null, isMinor ?? null, readinessNotes || null, accessibilityNotes || null, emergencyContactName || null, emergencyContactPhone || null, source || null, externalReference || null]
); let reg;
return rows[0]; 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) { async function getRegistration(pool, registrationId) {
const { rows } = await pool.query(`SELECT * FROM offering_registrations WHERE registration_id = $1`, [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 (!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); 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]); 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 // 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.'); 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); const offering = await getOffering(pool, reg.offering_id);
if (!offering) throw err('OFFERING_NOT_FOUND', 'No such offering.'); 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 const student = reg.student_id
? (await pool.query(`SELECT * FROM students WHERE student_id = $1`, [reg.student_id])).rows[0] ? (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]; 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 }) { 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 { rows: defRows } = await pool.query(`SELECT * FROM agreement_definitions WHERE agreement_id = $1`, [agreementId]);
const def = defRows[0]; const def = defRows[0];
if (!def) throw err('AGREEMENT_NOT_FOUND', 'No such agreement.'); 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( 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) `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 *`, VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
@@ -296,6 +412,99 @@ async function listAcceptancesForRegistration(pool, registrationId) {
return rows; 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 = { module.exports = {
createOrganization, listOrganizations, createOrganization, listOrganizations,
createLocation, listLocations, createLocation, listLocations,
@@ -303,5 +512,7 @@ module.exports = {
createCurriculumPackage, addCurriculumPackageItem, getCurriculumPackage, createCurriculumPackage, addCurriculumPackageItem, getCurriculumPackage,
createOffering, getOffering, listOfferings, transitionOffering, assignInstructorToOffering, reserveSeat, releaseSeat, createOffering, getOffering, listOfferings, transitionOffering, assignInstructorToOffering, reserveSeat, releaseSeat,
createRegistration, getRegistration, listRegistrationsForOffering, transitionRegistration, convertRegistrationToEnrollment, createRegistration, getRegistration, listRegistrationsForOffering, transitionRegistration, convertRegistrationToEnrollment,
createAgreementDefinition, recordAgreementAcceptance, listAcceptancesForRegistration, createAgreementDefinition, listAgreementDefinitions, recordAgreementAcceptance, listAcceptancesForRegistration,
addAgreementRequirement, listAgreementRequirementsForOffering, getRegistrationAgreementStatus,
createRegistrationFromIntegrationEvent, computeOfferingReadiness, assertRegistrationOpen,
}; };
@@ -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]));
}));
};
+5
View File
@@ -421,6 +421,11 @@ require('./admin-offerings-routes')(app, {
instructorSession: wrap(instructorSession), rbac, getPool, recordAudit, 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 // Fallback to the platform-shell SPA for any unmatched non-API route
app.get('*', (req, res) => { app.get('*', (req, res) => {
res.sendFile(path.join(FRONTEND_DIST, 'index.html')); res.sendFile(path.join(FRONTEND_DIST, 'index.html'));