Files
hope-events/backend/src/controllers/outboundWebhookController.js
T
joshuaandClaude Opus 5.5 4c066e67d9 Pagination and search on long lists (1.50.1)
Audit log: search (person, target, IP), role filter, every action in the
filter, inclusive To date. Data requests: search and paging (was cut at
200). Webhook deliveries: status filter and Load older. Supervisor Payments
loads only what it needs (unpaid registrations, recent/today/donation
payments, a person's own for refunds) instead of everything.

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

179 lines
8.7 KiB
JavaScript

const prisma = require('../config/db');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const webhooks = require('../utils/webhooks');
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: error.message });
const audit = (req, endpoint, action, metadata = {}) => logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'webhook_endpoint_changed', targetType: 'WebhookEndpoint', targetId: endpoint.id,
metadata: { action, name: endpoint.name, host: hostOf(endpoint.url), ...metadata }, ip: getClientIp(req),
});
function hostOf(url) {
try { return new URL(url).host; } catch { return null; }
}
/** What goes to an endpoint, in words, for its operator register entry. */
function dataShared(events) {
const parts = [];
if (events.some(e => e.startsWith('registration.') || e === 'ticket.checked_in' || e.startsWith('payment.') || e === 'user.created')) {
parts.push('names, email addresses and phone numbers of people who register or have an account');
}
if (events.some(e => e.startsWith('registration.'))) parts.push('what they registered for and its status');
if (events.some(e => e.startsWith('payment.'))) parts.push('payment and refund amounts');
if (events.includes('ticket.checked_in')) parts.push('check-in times');
if (events.includes('event.published')) parts.push('published event details');
return parts.length ? `${parts.join('; ')}.` : 'Nothing yet (no event types chosen).';
}
function cleanEvents(list) {
const events = [...new Set((Array.isArray(list) ? list : []).map(String))].filter(t => webhooks.TYPES.has(t));
if (!events.length) throw webhooks.webhookError('Choose at least one thing to send');
return events;
}
function cleanName(name) {
const n = String(name || '').trim();
if (!n) throw webhooks.webhookError('Give the webhook a name, e.g. the service it goes to');
return n.slice(0, 100);
}
const view = e => ({
id: e.id, name: e.name, url: e.url, events: e.events, isActive: e.isActive, operatorId: e.operatorId,
lastSuccessAt: e.lastSuccessAt, lastFailureAt: e.lastFailureAt, createdAt: e.createdAt,
});
// @desc Endpoints, the event types, and the last week's delivery counts per endpoint
// @route GET /api/outbound-webhooks
// @access Private/Admin
const list = async (req, res) => {
try {
const endpoints = await prisma.webhookEndpoint.findMany({ orderBy: { createdAt: 'asc' } });
const since = new Date(Date.now() - 7 * 864e5);
const counts = await prisma.webhookDelivery.groupBy({ by: ['endpointId', 'status'], where: { createdAt: { gte: since } }, _count: { _all: true } });
res.json({
eventTypes: webhooks.EVENT_TYPES,
keepDays: webhooks.KEEP_DAYS,
endpoints: endpoints.map(e => ({
...view(e),
lastWeek: Object.fromEntries(counts.filter(c => c.endpointId === e.id).map(c => [c.status, c._count._all])),
})),
});
} catch (error) { fail(res, error); }
};
// @desc Add an endpoint: the secret is in this answer only. Also added to the operator register.
// @route POST /api/outbound-webhooks { name, url, events }
// @access Private/Admin
const create = async (req, res) => {
try {
const name = cleanName(req.body?.name);
const url = webhooks.cleanUrl(req.body?.url);
const events = cleanEvents(req.body?.events);
const secret = webhooks.newSecret();
const u = new URL(url);
// POPIA: whoever receives this is an operator of the church's personal information.
const operator = await prisma.operator.create({
data: { name: `${name} (webhook)`, purpose: 'Receives notifications from this site through a webhook', dataShared: dataShared(events), website: u.origin },
}).catch(() => null);
const endpoint = await prisma.webhookEndpoint.create({ data: { name, url, events, secret: webhooks.encrypt(secret), operatorId: operator?.id || null } });
webhooks.forgetEndpoints();
audit(req, endpoint, 'created', { events });
res.status(201).json({ ...view(endpoint), secret });
} catch (error) { fail(res, error); }
};
// @route PUT /api/outbound-webhooks/:id { name?, url?, events?, isActive? }
// @access Private/Admin
const update = async (req, res) => {
try {
const current = await prisma.webhookEndpoint.findUnique({ where: { id: req.params.id } });
if (!current) return res.status(404).json({ message: 'Webhook not found' });
const data = {};
if (req.body?.name !== undefined) data.name = cleanName(req.body.name);
if (req.body?.url !== undefined) data.url = webhooks.cleanUrl(req.body.url);
if (req.body?.events !== undefined) data.events = cleanEvents(req.body.events);
if (req.body?.isActive !== undefined) data.isActive = req.body.isActive !== false;
const endpoint = await prisma.webhookEndpoint.update({ where: { id: current.id }, data });
if (current.operatorId && (data.events || data.url || data.name || data.isActive !== undefined)) {
await prisma.operator.update({
where: { id: current.operatorId },
data: { name: `${endpoint.name} (webhook)`, dataShared: dataShared(endpoint.events), website: new URL(endpoint.url).origin, active: endpoint.isActive },
}).catch(() => {});
}
webhooks.forgetEndpoints();
audit(req, endpoint, 'updated', { changedKeys: Object.keys(data) });
res.json(view(endpoint));
} catch (error) { fail(res, error); }
};
// @desc Delete an endpoint and its delivery log; its operator entry is marked inactive
// @route DELETE /api/outbound-webhooks/:id
// @access Private/Admin
const remove = async (req, res) => {
try {
const current = await prisma.webhookEndpoint.findUnique({ where: { id: req.params.id } });
if (!current) return res.status(404).json({ message: 'Webhook not found' });
await prisma.webhookEndpoint.delete({ where: { id: current.id } });
if (current.operatorId) await prisma.operator.update({ where: { id: current.operatorId }, data: { active: false } }).catch(() => {});
webhooks.forgetEndpoints();
audit(req, current, 'deleted');
res.json({ ok: true });
} catch (error) { fail(res, error); }
};
// @desc A new secret (the old one stops working at once); in this answer only
// @route POST /api/outbound-webhooks/:id/rotate-secret
// @access Private/Admin
const rotateSecret = async (req, res) => {
try {
const current = await prisma.webhookEndpoint.findUnique({ where: { id: req.params.id } });
if (!current) return res.status(404).json({ message: 'Webhook not found' });
const secret = webhooks.newSecret();
await prisma.webhookEndpoint.update({ where: { id: current.id }, data: { secret: webhooks.encrypt(secret) } });
audit(req, current, 'secret_rotated');
res.json({ secret });
} catch (error) { fail(res, error); }
};
// @desc Send a "webhook.test" now and say what came back
// @route POST /api/outbound-webhooks/:id/test
// @access Private/Admin
const test = async (req, res) => {
try {
if (!(await prisma.webhookEndpoint.findUnique({ where: { id: req.params.id }, select: { id: true } }))) return res.status(404).json({ message: 'Webhook not found' });
const d = await webhooks.sendTest(req.params.id);
res.json({ status: d?.status, statusCode: d?.lastStatusCode ?? null, error: d?.lastError || null });
} catch (error) { fail(res, error); }
};
// @desc An endpoint's deliveries, newest first (?status=failed to filter)
// @route GET /api/outbound-webhooks/:id/deliveries
// @access Private/Admin
const deliveries = async (req, res) => {
try {
const where = { endpointId: req.params.id, ...(req.query.status ? { status: String(req.query.status) } : {}) };
// "Load older": the next page is everything before the last one shown.
const before = req.query.before ? new Date(String(req.query.before)) : null;
if (before && !Number.isNaN(before.getTime())) where.createdAt = { lt: before };
const rows = await prisma.webhookDelivery.findMany({
where, orderBy: { createdAt: 'desc' }, take: Math.min(200, parseInt(req.query.limit, 10) || 50),
select: { id: true, eventId: true, type: true, status: true, attempts: true, nextAttemptAt: true, lastStatusCode: true, lastError: true, deliveredAt: true, createdAt: true, payload: true },
});
res.json(rows);
} catch (error) { fail(res, error); }
};
// @desc Send a delivery again now
// @route POST /api/outbound-webhooks/deliveries/:id/resend
// @access Private/Admin
const resend = async (req, res) => {
try {
const d = await webhooks.resend(req.params.id);
res.json({ status: d?.status, statusCode: d?.lastStatusCode ?? null, error: d?.lastError || null });
} catch (error) { fail(res, error); }
};
module.exports = { list, create, update, remove, rotateSecret, test, deliveries, resend, dataShared };