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
179 lines
8.7 KiB
JavaScript
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 };
|