44e5af6c2b
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>
142 lines
4.2 KiB
TypeScript
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');
|
|
});
|
|
}
|
|
}
|