import { prisma } from './db.js'; import { logger } from './logger.js'; import crypto from 'crypto'; export async function dispatchWebhooks(tenantId: string, event: string, data: any) { // Don't await this - fire and forget doDispatch(tenantId, event, data).catch((err) => { logger.error({ err, tenantId, event }, 'webhook dispatch failed'); }); } function isDiscordWebhook(url: string): boolean { return url.startsWith('https://discord.com/api/webhooks/') || url.startsWith('https://discordapp.com/api/webhooks/'); } async function getTenantBaseUrl(tenantId: string): Promise { const hostname = await prisma.tenantHostname.findFirst({ where: { tenantId, isPrimary: true }, select: { hostname: true } }); if (hostname) return `https://${hostname.hostname}`; // Fallback to any hostname const any = await prisma.tenantHostname.findFirst({ where: { tenantId }, select: { hostname: true } }); return any ? `https://${any.hostname}` : ''; } function formatDiscordPayload(event: string, data: any, baseUrl: string): object { const colors: Record = { 'card:created': 0x22c55e, // green 'card:updated': 0x3b82f6, // blue 'card:moved': 0xf59e0b, // amber 'card:deleted': 0xef4444, // red 'column:created': 0x10b981, // emerald 'column:deleted': 0xf97316, // orange 'board:updated': 0x8b5cf6, // purple }; const cardTitle = data.card?.title || data.cardTitle || 'Unknown'; const cardLink = data.cardUrl && baseUrl ? `[${cardTitle}](${baseUrl}${data.cardUrl})` : cardTitle; let description = ''; const fields: { name: string; value: string; inline?: boolean }[] = []; switch (event) { case 'card:created': description = `New card: ${cardLink}`; break; case 'card:updated': description = `Updated: ${cardLink}`; if (data.fields?.length) { fields.push({ name: 'Changed', value: data.fields.join(', '), inline: true }); } break; case 'card:moved': description = `Moved: ${cardLink}`; if (data.from && data.to) { fields.push({ name: 'From', value: data.from, inline: true }); fields.push({ name: 'To', value: data.to, inline: true }); } break; case 'card:deleted': description = `Deleted: **${cardTitle}**`; break; case 'column:created': description = `New column: **${data.column?.title || 'Unknown'}**`; break; case 'column:deleted': description = `Deleted column \`${data.columnId}\``; break; case 'board:updated': description = `Board updated: **${data.board?.title || 'Unknown'}**`; break; } return { embeds: [{ title: event.replace(':', ' ').replace(/\b\w/g, (c: string) => c.toUpperCase()), description, color: colors[event] || 0x6b7280, fields: fields.length > 0 ? fields : undefined, timestamp: new Date().toISOString(), footer: { text: 'Pounce' } }] }; } async function doDispatch(tenantId: string, event: string, data: any) { const webhooks = await prisma.webhook.findMany({ where: { tenantId, active: true, events: { has: event } } }); logger.info({ tenantId, event, webhookCount: webhooks.length }, 'dispatching webhooks'); if (webhooks.length === 0) return; // Resolve base URL once for Discord webhooks const hasDiscord = webhooks.some((w) => isDiscordWebhook(w.url)); const baseUrl = hasDiscord ? await getTenantBaseUrl(tenantId) : ''; for (const webhook of webhooks) { const isDiscord = isDiscordWebhook(webhook.url); let body: string; let headers: Record; if (isDiscord) { body = JSON.stringify(formatDiscordPayload(event, data, baseUrl)); headers = { 'Content-Type': 'application/json' }; } else { body = JSON.stringify({ event, data, timestamp: new Date().toISOString() }); const signature = crypto.createHmac('sha256', webhook.secret).update(body).digest('hex'); headers = { 'Content-Type': 'application/json', 'X-Pounce-Signature': signature, 'X-Pounce-Event': event }; } fetch(webhook.url, { method: 'POST', headers, body }) .then((res) => { if (!res.ok) { res.text().then((text) => { logger.warn({ webhookId: webhook.id, status: res.status, event, response: text.slice(0, 200) }, 'webhook delivery failed'); }); } }) .catch((err) => { logger.warn({ err, webhookId: webhook.id, event }, 'webhook delivery error'); }); } }