- 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]>
60 lines
3.1 KiB
JavaScript
60 lines
3.1 KiB
JavaScript
// 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]));
|
|
}));
|
|
};
|