From b6d65b7c316d9c0af786444e6871087e7aa6edd2 Mon Sep 17 00:00:00 2001 From: Catherine Renelle <32781029+catrenelle@users.noreply.github.com> Date: Sun, 8 Mar 2026 11:00:06 -0400 Subject: [PATCH] Add Discord webhook formatting and dispatch logging Discord webhooks require a specific payload format (embeds), not raw JSON. Auto-detect Discord URLs and send colored embeds with card/board details. Also adds logging for webhook dispatch debugging. Co-Authored-By: Claude Opus 4.6 --- src/lib/server/webhooks.ts | 96 ++++++++++++++++++++++++++++++++++---- 1 file changed, 87 insertions(+), 9 deletions(-) diff --git a/src/lib/server/webhooks.ts b/src/lib/server/webhooks.ts index f41d0a4..f5d7b36 100644 --- a/src/lib/server/webhooks.ts +++ b/src/lib/server/webhooks.ts @@ -1,9 +1,67 @@ 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(() => {}); + 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/'); +} + +function formatDiscordPayload(event: string, data: any): 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 titles: Record = { + 'card:created': 'Card Created', + 'card:updated': 'Card Updated', + 'card:moved': 'Card Moved', + 'card:deleted': 'Card Deleted', + 'column:created': 'Column Created', + 'column:deleted': 'Column Deleted', + 'board:updated': 'Board Updated', + }; + + const fields: { name: string; value: string; inline?: boolean }[] = []; + + if (data.card) { + fields.push({ name: 'Card', value: data.card.title || data.card.id || 'Unknown', inline: true }); + } + if (data.column) { + fields.push({ name: 'Column', value: data.column.title || data.column.id || 'Unknown', inline: true }); + } + if (data.board) { + fields.push({ name: 'Board', value: data.board.title || data.board.id || 'Unknown', inline: true }); + } + if (event === 'card:deleted') { + fields.push({ name: 'Card ID', value: data.cardId || 'Unknown', inline: true }); + } + if (event === 'column:deleted') { + fields.push({ name: 'Column ID', value: data.columnId || 'Unknown', inline: true }); + } + + return { + embeds: [{ + title: titles[event] || event, + color: colors[event] || 0x6b7280, + fields, + timestamp: new Date().toISOString(), + footer: { text: 'Pounce' } + }] + }; } async function doDispatch(tenantId: string, event: string, data: any) { @@ -11,19 +69,39 @@ async function doDispatch(tenantId: string, event: string, data: any) { where: { tenantId, active: true, events: { has: event } } }); - const payload = JSON.stringify({ event, data, timestamp: new Date().toISOString() }); + logger.info({ tenantId, event, webhookCount: webhooks.length }, 'dispatching webhooks'); + + if (webhooks.length === 0) return; for (const webhook of webhooks) { - const signature = crypto.createHmac('sha256', webhook.secret).update(payload).digest('hex'); + const isDiscord = isDiscordWebhook(webhook.url); - fetch(webhook.url, { - method: 'POST', - headers: { + let body: string; + let headers: Record; + + if (isDiscord) { + body = JSON.stringify(formatDiscordPayload(event, data)); + 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 - }, - body: payload - }).catch(() => {}); + }; + } + + 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'); + }); } }