- 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
392 lines
20 KiB
JavaScript
392 lines
20 KiB
JavaScript
/**
|
||
* 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,
|
||
};
|