Private
Public Access
1
0
Files
Kanban/src/lib/server/webhooks.ts
T
Catherine Renelle 44e5af6c2b Enrich Discord webhook embeds with move details and card links
Card moved events now show from/to column names. Card created/updated
embeds link directly to the card. Card updated shows which fields
changed. Card deleted shows the card title. Base URL resolved from
tenant hostname for full links.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 11:05:17 -04:00

142 lines
4.2 KiB
TypeScript

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<string> {
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<string, number> = {
'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<string, string>;
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');
});
}
}