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 };