Files
hope-events/backend/src/utils/webhooks.js
T
joshuaandClaude Opus 5.5 8f6f90a151 Searchable event picker and webhook guide (1.50.2)
- EventPicker replaces the plain event dropdowns on staff/supervisor pages and the kiosk:
  upcoming first, past behind "Show past events", search across all, keyboard support.
- Reports events list can be searched ("Select these N").
- Site settings -> Webhooks: "How webhooks work" guide with headers, signature checking
  (Node.js/PHP) and an example of every event type (EVENT_TYPES[].example, shape-tested).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 08:23:09 +02:00

392 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Outbound webhooks (1.48.0): admins add endpoints (Site settings → Webhooks) and choose event
* types; what happens here is POSTed to them as JSON, signed with the endpoint's secret.
*
* - Events are noticed in one place, a Prisma client extension (config/db.js), so every path that
* creates a registration, marks it paid, records a payment, checks a ticket in… is covered.
* - `notice` only queues (in memory); about a second later `flush` reads the record again — a
* transaction that rolled back, or a status that changed back, sends nothing — builds the
* payload and stores one WebhookDelivery per endpoint that wants the type, then sends them.
* - Sending: `X-CrossEvents-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>">`
* (like Stripe), 10 s timeout, redirects not followed, 2xx = delivered. Failures are retried
* after 1 min, 5 min, 30 min, 2 h and 12 h, then marked failed (Resend on the delivery log).
* - Only https URLs on public addresses (no SSRF into the server's own network), unless
* WEBHOOKS_ALLOW_PRIVATE=true (local testing).
* - Never sent: kids check-in and volunteer tickets (children's data stays here).
* - Deliveries hold personal information, so they're kept 30 days and removed with an account.
*/
const crypto = require('crypto');
const dns = require('dns').promises;
const net = require('net');
const prisma = require('../config/db');
const { encrypt, decrypt } = require('./encryption');
const EVENT_TYPES = [
{ type: 'registration.created', label: 'Registration made', description: 'Someone registers (online, at the door, by staff or with a season pass).' },
{ type: 'registration.paid', label: 'Registration paid', description: 'A registration is paid in full — or made free.' },
{ type: 'registration.cancelled', label: 'Registration cancelled', description: 'A registration is cancelled.' },
{ type: 'payment.received', label: 'Payment received', description: 'Any payment or donation is recorded (online or by staff).' },
{ type: 'payment.refunded', label: 'Payment refunded', description: 'A refund is recorded.' },
{ type: 'ticket.checked_in', label: 'Ticket checked in', description: 'A ticket is scanned or ticked present.' },
{ type: 'user.created', label: 'Account created', description: 'A new account is made.' },
{ type: 'event.published', label: 'Event published', description: 'An event becomes public (made active and not hidden).' },
];
const TYPES = new Set(EVENT_TYPES.map(t => t.type));
// What `data` looks like for each type — shown in Site settings → Webhooks so whoever builds the
// receiving side knows what arrives. tests/webhooks.test.js checks these match buildPayload.
const EXAMPLE_EVENT = { id: 'c1a2b3c4-0000-4000-8000-000000000001', title: 'Youth Camp 2026', startDate: '2026-12-04T16:00:00.000Z', endDate: '2026-12-06T12:00:00.000Z' };
const EXAMPLE_PERSON = { id: 'u1a2b3c4-0000-4000-8000-000000000002', name: 'Thandi Mokoena', email: 'thandi@example.com', phone: '+27821234567' };
const exampleRegistration = (status, amountPaid) => ({
registration: {
id: 'r1a2b3c4-0000-4000-8000-000000000003', status, createdAt: '2026-10-29T08:15:00.000Z', totalDue: 1200, amountPaid,
seasonPassPurchaseId: null,
items: [{ ticket: 'Camper', variant: 'Bunk bed', quantity: 2, unitPrice: 600 }],
},
event: EXAMPLE_EVENT,
person: EXAMPLE_PERSON,
});
const examplePayment = (amount) => ({
payment: { id: 'p1a2b3c4-0000-4000-8000-000000000004', amount, method: 'card', provider: 'yoco', isDonation: false, registrationId: 'r1a2b3c4-0000-4000-8000-000000000003', createdAt: '2026-10-29T08:16:00.000Z' },
event: EXAMPLE_EVENT,
person: EXAMPLE_PERSON,
});
const EXAMPLES = {
'registration.created': exampleRegistration('pending', 0),
'registration.paid': exampleRegistration('paid', 1200),
'registration.cancelled': exampleRegistration('cancelled', 0),
'payment.received': examplePayment(1200),
// Refunds are recorded as negative payments.
'payment.refunded': examplePayment(-600),
'ticket.checked_in': {
checkIn: { id: 't1a2b3c4-0000-4000-8000-000000000005', scannedAt: '2026-12-04T16:20:00.000Z', people: 1, manual: false, offline: false, session: { id: 's1a2b3c4-0000-4000-8000-000000000006', label: 'Friday evening', startsAt: '2026-12-04T16:00:00.000Z' } },
ticket: { id: 'k1a2b3c4-0000-4000-8000-000000000007', registrationOptionId: 'o1a2b3c4-0000-4000-8000-000000000008', holderName: 'Thandi Mokoena' },
event: EXAMPLE_EVENT,
person: EXAMPLE_PERSON,
},
'user.created': { person: { ...EXAMPLE_PERSON, role: 'user', createdAt: '2026-10-29T08:10:00.000Z' } },
'event.published': {
event: {
...EXAMPLE_EVENT, description: 'A weekend away for grades 8–12.', location: 'Camp Joy, Magaliesburg', price: 600,
goLiveAt: '2026-10-29T06:00:00.000Z', url: 'https://events.example.org/events/c1a2b3c4-0000-4000-8000-000000000001',
campus: { name: 'North', slug: 'north' }, series: null,
},
},
};
for (const t of EVENT_TYPES) t.example = EXAMPLES[t.type];
const RETRY_AFTER_MS = [60e3, 5 * 60e3, 30 * 60e3, 2 * 3600e3, 12 * 3600e3];
const MAX_ATTEMPTS = RETRY_AFTER_MS.length + 1;
const KEEP_DAYS = 30;
const TIMEOUT_MS = 10000;
function webhookError(message, statusCode = 400) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}
const allowPrivate = () => String(process.env.WEBHOOKS_ALLOW_PRIVATE || '').toLowerCase() === 'true';
// ─── Endpoint addresses ──────────────────────────────────────────────────────
/** Whether an IP address is private, loopback, link-local or otherwise not on the internet. */
function isPrivateAddress(ip) {
if (net.isIPv4(ip)) {
const [a, b] = ip.split('.').map(Number);
return a === 10 || a === 127 || a === 0 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31)
|| (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127) || a >= 224;
}
if (net.isIPv6(ip)) {
const v = ip.toLowerCase();
if (v === '::1' || v === '::') return true;
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(v);
if (mapped) return isPrivateAddress(mapped[1]);
return /^f[cd]/.test(v) || /^fe[89ab]/.test(v);
}
return true;
}
/** A cleaned endpoint URL, or an error: https (http only for local testing), no user:password. */
function cleanUrl(raw) {
let u;
try { u = new URL(String(raw || '').trim()); } catch { throw webhookError('Enter the full address, starting with https://'); }
if (u.protocol !== 'https:' && !(u.protocol === 'http:' && allowPrivate())) throw webhookError('The address must start with https://');
if (u.username || u.password) throw webhookError('Leave the user name and password out of the address');
if (!allowPrivate() && (u.hostname === 'localhost' || (net.isIP(u.hostname) && isPrivateAddress(u.hostname)))) {
throw webhookError('That address is on a private network — webhooks only go to public addresses');
}
return u.toString();
}
/** Refuses a host that resolves to a private address (checked at every send: DNS can change). */
async function assertPublicHost(hostname) {
if (allowPrivate()) return;
const addresses = net.isIP(hostname) ? [{ address: hostname }] : await dns.lookup(hostname, { all: true });
if (!addresses.length || addresses.some(a => isPrivateAddress(a.address))) {
throw webhookError(`${hostname} points to a private network address`);
}
}
// ─── Signing ─────────────────────────────────────────────────────────────────
const newSecret = () => `whsec_${crypto.randomBytes(24).toString('base64url')}`;
/** The signature header for a body: t=<seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>">. */
function signatureHeader(secret, body, at = Date.now()) {
const t = Math.floor(at / 1000);
const v1 = crypto.createHmac('sha256', secret).update(`${t}.${body}`).digest('hex');
return `t=${t},v1=${v1}`;
}
// ─── Endpoints (cached for `notice`) ─────────────────────────────────────────
let endpointCache = null;
let endpointCacheAt = 0;
async function activeEndpoints() {
if (!endpointCache || Date.now() - endpointCacheAt > 60e3) {
endpointCache = await prisma.webhookEndpoint.findMany({ where: { isActive: true }, select: { id: true, events: true } });
endpointCacheAt = Date.now();
}
return endpointCache;
}
function forgetEndpoints() { endpointCache = null; }
// ─── Noticing events ─────────────────────────────────────────────────────────
const queue = [];
let flushTimer = null;
const FLUSH_DELAY_MS = 1500;
/**
* Something happened (from the Prisma extension). Queued and checked again shortly; never throws.
* `ref` is the record's id.
*/
function notice(type, ref) {
if (!TYPES.has(type) || !ref) return;
if (String(process.env.WEBHOOKS_ENABLED || 'true').toLowerCase() === 'false') return;
queue.push({ type, ref, at: new Date() });
if (!flushTimer) {
flushTimer = setTimeout(() => { flushTimer = null; flush().catch(e => console.warn('[webhooks] flush:', e?.message || e)); }, FLUSH_DELAY_MS);
flushTimer.unref?.();
}
}
/** Builds and stores the deliveries for what was noticed, then sends them. */
async function flush() {
const items = queue.splice(0, queue.length);
if (!items.length) return [];
const endpoints = await activeEndpoints();
const made = [];
for (const item of items) {
const targets = endpoints.filter(e => e.events.includes(item.type));
if (!targets.length) continue;
let built;
try { built = await buildPayload(item.type, item.ref); } catch (e) { console.warn(`[webhooks] ${item.type} ${item.ref}:`, e?.message || e); continue; }
if (!built) continue;
const eventId = `evt_${crypto.randomUUID()}`;
const payload = { id: eventId, type: item.type, createdAt: item.at.toISOString(), data: built.data };
for (const ep of targets) {
made.push(await prisma.webhookDelivery.create({ data: { endpointId: ep.id, eventId, type: item.type, payload, userId: built.userId || null } }));
}
}
for (const d of made) await send(d.id).catch(e => console.warn('[webhooks] send:', e?.message || e));
return made;
}
// ─── Payloads ────────────────────────────────────────────────────────────────
const frontendUrl = () => String(process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
const person = u => (u ? { id: u.id, name: u.name, email: u.email, phone: u.phoneNumber || null } : null);
const eventRef = e => (e ? { id: e.id, title: e.title, startDate: e.startDate, endDate: e.endDate } : null);
const reservedIds = e => new Set([e?.kidsOptionId, e?.volunteerOptionId].filter(Boolean));
const EVENT_SELECT = { id: true, title: true, startDate: true, endDate: true, kidsOptionId: true, volunteerOptionId: true };
const USER_SELECT = { id: true, name: true, email: true, phoneNumber: true };
async function registrationData(id, status) {
const r = await prisma.registration.findUnique({
where: { id },
include: {
event: { select: EVENT_SELECT }, user: { select: USER_SELECT }, payments: true,
registrationOptions: { include: { eventOption: { select: { id: true, name: true } }, variant: { select: { name: true } }, tranches: true } },
},
});
if (!r || (status && r.status !== status)) return null;
// Kids check-in and volunteer places are internal (and can be about children): never sent.
const reserved = reservedIds(r.event);
if (r.registrationOptions.length && r.registrationOptions.every(o => reserved.has(o.eventOptionId))) return null;
const { computeRegistrationTotalDue } = require('./pricing');
const totalDue = Math.round(computeRegistrationTotalDue(r, new Date()) * 100) / 100;
const paid = Math.round(r.payments.reduce((s, p) => s + p.amount, 0) * 100) / 100;
return {
userId: r.userId,
data: {
registration: {
id: r.id, status: r.status, createdAt: r.createdAt, totalDue, amountPaid: paid,
seasonPassPurchaseId: r.seasonPassPurchaseId || null,
items: r.registrationOptions.filter(o => !reserved.has(o.eventOptionId)).map(o => ({ ticket: o.eventOption?.name, variant: o.variant?.name || null, quantity: o.quantity, unitPrice: o.priceSnapshot })),
},
event: eventRef(r.event),
person: person(r.user),
},
};
}
async function buildPayload(type, ref) {
switch (type) {
case 'registration.created': return registrationData(ref);
case 'registration.paid': return registrationData(ref, 'paid');
case 'registration.cancelled': return registrationData(ref, 'cancelled');
case 'payment.received':
case 'payment.refunded': {
const p = await prisma.payment.findUnique({ where: { id: ref }, include: { user: { select: USER_SELECT }, event: { select: EVENT_SELECT } } });
if (!p) return null;
return {
userId: p.userId,
data: {
payment: { id: p.id, amount: p.amount, method: p.method, provider: p.provider || null, isDonation: p.isDonation, registrationId: p.registrationId, createdAt: p.createdAt },
event: eventRef(p.event),
person: person(p.user),
},
};
}
case 'ticket.checked_in': {
const u = await prisma.ticketUsage.findUnique({
where: { id: ref },
include: {
session: { select: { id: true, label: true, startsAt: true } },
ticket: { include: { event: { select: EVENT_SELECT }, user: { select: USER_SELECT }, holder: { select: USER_SELECT }, attendee: { select: { name: true } }, registrationOption: { select: { eventOptionId: true } } } },
},
});
if (!u) return null;
if (reservedIds(u.ticket.event).has(u.ticket.registrationOption?.eventOptionId)) return null;
// A transferred ticket belongs to its holder now.
const who = u.ticket.holder || u.ticket.user;
return {
userId: who?.id || u.ticket.userId,
data: {
checkIn: { id: u.id, scannedAt: u.scannedAt, people: u.quantityRedeemed, manual: u.manual, offline: u.offline, session: u.session || null },
ticket: { id: u.ticket.id, registrationOptionId: u.ticket.registrationOptionId, holderName: u.ticket.attendee?.name || who?.name || null },
event: eventRef(u.ticket.event),
person: person(who),
},
};
}
case 'user.created': {
const user = await prisma.user.findUnique({ where: { id: ref }, select: { ...USER_SELECT, role: true, createdAt: true } });
if (!user) return null;
return { userId: user.id, data: { person: { ...person(user), role: user.role, createdAt: user.createdAt } } };
}
case 'event.published': {
const e = await prisma.event.findUnique({
where: { id: ref },
select: { ...EVENT_SELECT, description: true, location: true, price: true, isActive: true, isHidden: true, goLiveAt: true, campus: { select: { name: true, slug: true } }, series: { select: { name: true, slug: true } } },
});
if (!e || !e.isActive || e.isHidden) return null;
return {
userId: null,
data: {
event: {
id: e.id, title: e.title, description: e.description, startDate: e.startDate, endDate: e.endDate, location: e.location,
price: e.price, goLiveAt: e.goLiveAt, url: `${frontendUrl()}/events/${e.id}`, campus: e.campus || null, series: e.series || null,
},
},
};
}
default: return null;
}
}
// ─── Sending ─────────────────────────────────────────────────────────────────
/** The wait before the next try after `attempts` failed tries, or null when it's given up. Pure. */
function retryDelay(attempts) {
return attempts < MAX_ATTEMPTS ? RETRY_AFTER_MS[attempts - 1] : null;
}
/** POSTs one delivery (claimed first, so two workers never send it twice). */
async function send(deliveryId) {
const claimed = await prisma.webhookDelivery.updateMany({ where: { id: deliveryId, status: { in: ['pending', 'retrying'] } }, data: { status: 'sending', nextAttemptAt: new Date() } });
if (!claimed.count) return null;
const d = await prisma.webhookDelivery.findUnique({ where: { id: deliveryId }, include: { endpoint: true } });
const attempts = d.attempts + 1;
const body = JSON.stringify(d.payload);
let statusCode = null;
let error = null;
try {
if (!d.endpoint.isActive) throw webhookError('The endpoint is switched off');
const url = new URL(cleanUrl(d.endpoint.url));
await assertPublicHost(url.hostname);
const res = await fetch(url, {
method: 'POST',
redirect: 'manual',
signal: AbortSignal.timeout(TIMEOUT_MS),
headers: {
'Content-Type': 'application/json',
'User-Agent': 'CrossEvents-Webhooks/1',
'X-CrossEvents-Event': d.type,
'X-CrossEvents-Delivery': d.id,
'X-CrossEvents-Signature': signatureHeader(decrypt(d.endpoint.secret), body),
},
body,
});
statusCode = res.status;
if (res.status < 200 || res.status >= 300) error = `Answered ${res.status}${res.status >= 300 && res.status < 400 ? ' (redirects are not followed)' : ''}`;
} catch (e) {
error = e?.name === 'TimeoutError' ? `No answer within ${TIMEOUT_MS / 1000} seconds` : (e?.message || String(e));
}
const now = new Date();
const ok = !error;
const wait = ok ? null : retryDelay(attempts);
const updated = await prisma.webhookDelivery.update({
where: { id: d.id },
data: {
attempts, lastStatusCode: statusCode, lastError: error ? String(error).slice(0, 500) : null,
status: ok ? 'succeeded' : wait ? 'retrying' : 'failed',
deliveredAt: ok ? now : null,
nextAttemptAt: wait ? new Date(now.getTime() + wait) : now,
},
});
await prisma.webhookEndpoint.update({ where: { id: d.endpointId }, data: ok ? { lastSuccessAt: now } : { lastFailureAt: now } }).catch(() => {});
return updated;
}
/** The `webhooks` job: sends what's due, picks up sends cut off by a restart, prunes old deliveries. */
async function runJob(now = new Date()) {
// A send is claimed with nextAttemptAt = now; one still "sending" 5 minutes later was cut off.
await prisma.webhookDelivery.updateMany({ where: { status: 'sending', nextAttemptAt: { lt: new Date(now.getTime() - 5 * 60e3) } }, data: { status: 'retrying' } });
const due = await prisma.webhookDelivery.findMany({ where: { status: { in: ['pending', 'retrying'] }, nextAttemptAt: { lte: now } }, orderBy: { nextAttemptAt: 'asc' }, take: 100, select: { id: true } });
let sent = 0;
let failed = 0;
for (const { id } of due) {
const r = await send(id).catch(() => null);
if (r?.status === 'succeeded') sent++;
else if (r) failed++;
}
const pruned = await prisma.webhookDelivery.deleteMany({ where: { createdAt: { lt: new Date(now.getTime() - KEEP_DAYS * 864e5) } } });
return { due: due.length, sent, failed, pruned: pruned.count };
}
/** Sends a delivery again now (a fresh try, whatever happened before). */
async function resend(deliveryId) {
const d = await prisma.webhookDelivery.findUnique({ where: { id: deliveryId }, select: { id: true } });
if (!d) throw webhookError('Delivery not found', 404);
await prisma.webhookDelivery.update({ where: { id: d.id }, data: { status: 'pending', attempts: 0, nextAttemptAt: new Date() } });
return send(d.id);
}
/** A "webhook.test" delivery to one endpoint, sent at once. */
async function sendTest(endpointId) {
const eventId = `evt_${crypto.randomUUID()}`;
const payload = { id: eventId, type: 'webhook.test', createdAt: new Date().toISOString(), data: { message: 'A test from Cross Events. If you can read this, the webhook works.' } };
const d = await prisma.webhookDelivery.create({ data: { endpointId, eventId, type: 'webhook.test', payload } });
return send(d.id);
}
module.exports = {
EVENT_TYPES, TYPES, MAX_ATTEMPTS, KEEP_DAYS,
isPrivateAddress, cleanUrl, assertPublicHost, newSecret, signatureHeader, encrypt,
notice, flush, buildPayload, retryDelay, send, runJob, resend, sendTest, forgetEndpoints, webhookError,
};