Private
Public Access
1
0

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>
This commit is contained in:
Catherine Renelle
2026-03-08 11:05:17 -04:00
parent b6d65b7c31
commit 44e5af6c2b
3 changed files with 90 additions and 37 deletions
+61 -27
View File
@@ -14,7 +14,21 @@ function isDiscordWebhook(url: string): boolean {
url.startsWith('https://discordapp.com/api/webhooks/');
}
function formatDiscordPayload(event: string, data: any): object {
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
@@ -25,39 +39,55 @@ function formatDiscordPayload(event: string, data: any): object {
'board:updated': 0x8b5cf6, // purple
};
const titles: Record<string, string> = {
'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 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 }[] = [];
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 });
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: titles[event] || event,
title: event.replace(':', ' ').replace(/\b\w/g, (c: string) => c.toUpperCase()),
description,
color: colors[event] || 0x6b7280,
fields,
fields: fields.length > 0 ? fields : undefined,
timestamp: new Date().toISOString(),
footer: { text: 'Pounce' }
}]
@@ -73,6 +103,10 @@ async function doDispatch(tenantId: string, event: string, data: any) {
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);
@@ -80,7 +114,7 @@ async function doDispatch(tenantId: string, event: string, data: any) {
let headers: Record<string, string>;
if (isDiscord) {
body = JSON.stringify(formatDiscordPayload(event, data));
body = JSON.stringify(formatDiscordPayload(event, data, baseUrl));
headers = { 'Content-Type': 'application/json' };
} else {
body = JSON.stringify({ event, data, timestamp: new Date().toISOString() });