- webhook-routes.js: POST /api/webhooks/payment — signature-verified, idempotent, always 200 once verified (a forged/invalid signature is a clean quiet 400) - integration-routes.js: POST /api/integrations/registrations/:id/agreements (closes Phase 8's gap — agreement acceptance now on the public registration contract), POST .../:id/checkout (server-to-server, amount always server-side), GET .../:id/status (minimal safe status lookup for the website's success page — no PII, no amount, no provider reference) - public-offerings-routes.js: price_amount/price_currency/requires_payment in the public serializer - metrics.js: checkout/payment/webhook/conversion/seat-released counters, all low-cardinality - notifications.js: listForRegistration now unions by the registration's own enrollment_id too, so the post-conversion activation notification shows up in the registration's communication history (previously only pre-conversion notifications were visible)
237 lines
14 KiB
JavaScript
237 lines
14 KiB
JavaScript
// Phase 5B §12 — the website/commerce integration boundary. POST /api/integrations/enrollments
|
|
// is the ONLY thing an external system (the public website, a future payment provider) ever
|
|
// calls. It is completely separate from both student and instructor session auth — no
|
|
// rn_session/rn_instructor cookie is ever read here, and this route accepts none — so a
|
|
// browser cannot reach it just by having a logged-in student or admin tab open. See
|
|
// docs/WEBSITE_ENROLLMENT_INTEGRATION.md for the full contract the website team should build
|
|
// against.
|
|
//
|
|
// Auth follows the exact same pattern as platform/provisioner/src/server.js's
|
|
// X-Provisioner-Key (a pre-shared secret in an env var, checked via a custom header, fatal at
|
|
// startup if unset) — the same internal server-to-server trust boundary this codebase already
|
|
// established for Gateway<->Provisioner, not a new scheme. The header value must be held by
|
|
// the website's OWN backend only; it is never sent to or readable by browser JS.
|
|
|
|
const enrollment = require('./enrollment');
|
|
const studentsAuth = require('./students-auth');
|
|
const notifications = require('./notifications');
|
|
const programsLib = require('./programs');
|
|
const offerings = require('./offerings');
|
|
const commerce = require('./commerce');
|
|
const { getPaymentProvider } = require('./payment-provider');
|
|
const metrics = require('./metrics');
|
|
|
|
const INTEGRATION_SHARED_SECRET = process.env.INTEGRATION_SHARED_SECRET;
|
|
|
|
if (!INTEGRATION_SHARED_SECRET) {
|
|
console.error('WARNING: INTEGRATION_SHARED_SECRET is not set — POST /api/integrations/enrollments will refuse every request until it is. This is fine for a dev stack that never exercises the integration boundary, fatal for anything that needs to.');
|
|
}
|
|
|
|
// Small, deliberately provider-neutral vocabulary — NOT Stripe's (or anyone else's) object
|
|
// model. The website's backend translates whatever its payment provider says into one of these
|
|
// two values before calling this endpoint.
|
|
const STATUS_MAP = { paid: 'active', active: 'active', confirmed: 'active', pending: 'pending', awaiting_payment: 'pending' };
|
|
|
|
function enrollmentErrorStatus(code) {
|
|
if (code === 'PROGRAM_NOT_FOUND') return 404;
|
|
if (code === 'PROGRAM_INACTIVE') return 409;
|
|
if (code === 'EVENT_ID_REQUIRED' || code === 'STUDENT_IDENTIFIER_REQUIRED') return 400;
|
|
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;
|
|
|
|
app.post('/api/integrations/enrollments', wrap(async (req, res) => {
|
|
const key = req.get('X-RootNode-Integration-Key');
|
|
if (!INTEGRATION_SHARED_SECRET || !key || key !== INTEGRATION_SHARED_SECRET) {
|
|
metrics.recordEnrollmentEvent('rejected');
|
|
return res.status(401).json({ error: 'unauthorized' });
|
|
}
|
|
|
|
const { event_id, source, student, program_slug, status } = req.body || {};
|
|
const email = student?.email ? String(student.email).trim() : null;
|
|
const loginIdentifier = student?.login_identifier ? String(student.login_identifier).trim() : null;
|
|
// Phase 5C §10 — an OPTIONAL enrollment-contact address, for a minor with no email of their
|
|
// own (e.g. a guardian's address collected at checkout). Never treated as the student's own
|
|
// identity — see enrollment.js's enrollmentContactEmail.
|
|
const contactEmail = student?.contact_email ? String(student.contact_email).trim() : null;
|
|
const mappedStatus = STATUS_MAP[status];
|
|
|
|
if (!event_id || typeof event_id !== 'string') {
|
|
metrics.recordEnrollmentEvent('rejected');
|
|
return res.status(400).json({ error: 'event_id is required' });
|
|
}
|
|
if (!program_slug) {
|
|
metrics.recordEnrollmentEvent('rejected');
|
|
return res.status(400).json({ error: 'program_slug is required' });
|
|
}
|
|
if (!mappedStatus) {
|
|
metrics.recordEnrollmentEvent('rejected');
|
|
return res.status(400).json({ error: 'status must be one of: ' + Object.keys(STATUS_MAP).join(', ') });
|
|
}
|
|
if (!email && !loginIdentifier) {
|
|
metrics.recordEnrollmentEvent('rejected');
|
|
return res.status(400).json({ error: 'student.email or student.login_identifier is required' });
|
|
}
|
|
|
|
let result;
|
|
try {
|
|
result = await enrollment.createFromIntegrationEvent(getPool(), studentsAuth, {
|
|
eventId: event_id, source: source || 'integration', studentEmail: email, studentLoginIdentifier: loginIdentifier,
|
|
studentDisplayName: student?.display_name, contactEmail, programId: program_slug, status: mappedStatus,
|
|
});
|
|
} catch (e) {
|
|
metrics.recordEnrollmentEvent('rejected');
|
|
if (e.code) return res.status(enrollmentErrorStatus(e.code)).json({ error: e.code, message: e.message });
|
|
throw e;
|
|
}
|
|
|
|
metrics.recordEnrollmentEvent(result.created ? 'created' : 'duplicate');
|
|
// Audited regardless of created/duplicate — a replay is still a real, worth-recording
|
|
// event, just not a new enrollment. No payment/card data ever reaches this far (the
|
|
// payload never carries it — see docs/WEBSITE_ENROLLMENT_INTEGRATION.md "what NOT to send").
|
|
await recordAudit(getPool(), {
|
|
actorType: 'integration', actorId: source || 'integration', action: result.created ? 'enrollment_integration_created' : 'enrollment_integration_duplicate',
|
|
resourceType: 'enrollment', resourceId: result.enrollment.enrollment_id, result: 'success', sourceIp: req.ip,
|
|
metadata: { event_id, program_slug, student_id: result.enrollment.student_id },
|
|
});
|
|
|
|
// Phase 5C §11/§12 — notification decision, ONLY on a genuinely new enrollment
|
|
// (result.created), never on an idempotent replay. This is the entire duplicate-webhook
|
|
// safety net: the enrollment's own DB-level idempotency (proven in Phase 5B) already
|
|
// guarantees at most one caller ever sees created:true for a given event/race, so at most
|
|
// one notification is ever queued, with no separate notification-level dedup needed.
|
|
if (result.created) {
|
|
const program = await programsLib.getProgram(getPool(), program_slug);
|
|
await notifications.notifyForEnrollment(getPool(), studentsAuth, {
|
|
studentId: result.enrollment.student_id, enrollmentId: result.enrollment.enrollment_id, programName: program?.name,
|
|
});
|
|
}
|
|
|
|
res.status(result.created ? 201 : 200).json({
|
|
enrollment_id: result.enrollment.enrollment_id,
|
|
student_id: result.enrollment.student_id,
|
|
program_id: result.enrollment.program_id,
|
|
status: result.enrollment.status,
|
|
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,
|
|
});
|
|
}));
|
|
|
|
// Phase 9 §6 — closes the one gap Phase 8 explicitly left open: agreement acceptance is now
|
|
// reachable from the public registration contract. An adult accepts as 'registrant' (their
|
|
// own student_id doesn't exist yet); a minor's guardian accepts as 'guardian', using the
|
|
// guardian_id the registration already carries from submission. Same shared-secret boundary
|
|
// as every other route in this file.
|
|
app.post('/api/integrations/registrations/:id/agreements', 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 registration = await offerings.getRegistration(getPool(), req.params.id);
|
|
if (!registration) return res.status(404).json({ error: 'NOT_FOUND' });
|
|
const acceptedByType = registration.is_minor ? 'guardian' : 'registrant';
|
|
try {
|
|
const acceptance = await offerings.recordAgreementAcceptance(getPool(), {
|
|
agreementId: req.body?.agreement_id, acceptedByType,
|
|
acceptedByGuardianId: registration.is_minor ? registration.guardian_id : undefined,
|
|
registrationId: registration.registration_id, ipAddress: req.ip,
|
|
});
|
|
await recordAudit(getPool(), { actorType: 'integration', actorId: 'website', action: 'agreement_accepted', resourceType: 'registration', resourceId: registration.registration_id, result: 'success', sourceIp: req.ip, metadata: { agreement_id: req.body?.agreement_id } });
|
|
res.status(201).json({ accepted: true, agreement_id: acceptance.agreement_id, accepted_version: acceptance.accepted_version });
|
|
} catch (e) {
|
|
if (e.code) return res.status(e.code === 'AGREEMENT_NOT_FOUND' ? 404 : 400).json({ error: e.code, message: e.message });
|
|
throw e;
|
|
}
|
|
}));
|
|
|
|
// Phase 9 §5 — server-to-server checkout creation. The website's backend calls this with a
|
|
// registration_id it already has (from the registration-submission response above); the
|
|
// browser never talks to this endpoint directly, and never supplies an amount/currency —
|
|
// both always come from the offering's own server-side price (commerce.js's own
|
|
// createCheckoutSession, Step 5's explicit "browser cannot choose price" requirement).
|
|
app.post('/api/integrations/registrations/:id/checkout', 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 { success_url, cancel_url } = req.body || {};
|
|
if (!success_url || !cancel_url) return res.status(400).json({ error: 'VALIDATION', message: 'success_url and cancel_url are required.' });
|
|
try {
|
|
const provider = getPaymentProvider();
|
|
const result = await commerce.createCheckoutSession(getPool(), provider, req.params.id, { successUrl: success_url, cancelUrl: cancel_url });
|
|
await recordAudit(getPool(), { actorType: 'integration', actorId: 'website', action: 'checkout_created', resourceType: 'registration', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { transaction_id: result.transactionId } });
|
|
res.status(201).json({ checkout_url: result.checkoutUrl, transaction_id: result.transactionId });
|
|
} catch (e) {
|
|
if (e.code) {
|
|
const status = { NOT_FOUND: 404, OFFERING_NOT_FOUND: 404, NO_PRICE_CONFIGURED: 409, INVALID_STATE: 409, AGREEMENTS_INCOMPLETE: 409, OFFERING_NOT_OPEN: 409, OFFERING_CANCELLED: 409, OFFERING_COMPLETED: 409, OFFERING_FULL: 409, REGISTRATION_NOT_YET_OPEN: 409, REGISTRATION_CLOSED: 409, PROVIDER_NOT_CONFIGURED: 503 }[e.code] || 500;
|
|
return res.status(status).json({ error: e.code, message: e.message });
|
|
}
|
|
throw e;
|
|
}
|
|
}));
|
|
|
|
// Phase 9 §16/§17 — a safe status lookup for the website's post-checkout success page. NEVER
|
|
// derive "paid" from a URL param the browser controls (a returning `?paid=true` proves
|
|
// nothing) — the website's backend polls this instead, server-to-server, same shared secret,
|
|
// until it sees a terminal state. Deliberately minimal: no PII, no payment amount, no provider
|
|
// reference, nothing an admin view would show — just enough to render "processing" /
|
|
// "confirmed" / "action needed" on the success page.
|
|
app.get('/api/integrations/registrations/:id/status', 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 registration = await offerings.getRegistration(getPool(), req.params.id);
|
|
if (!registration) return res.status(404).json({ error: 'NOT_FOUND' });
|
|
const transactions = await commerce.listTransactionsForRegistration(getPool(), registration.registration_id);
|
|
res.json({
|
|
registration_id: registration.registration_id,
|
|
status: registration.status,
|
|
payment_status: transactions[0]?.status || null,
|
|
enrollment_activated: registration.status === 'enrollment_activated',
|
|
});
|
|
}));
|
|
};
|