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