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
+59 -25
View File
@@ -14,7 +14,21 @@ function isDiscordWebhook(url: string): boolean {
url.startsWith('https://discordapp.com/api/webhooks/'); 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> = { const colors: Record<string, number> = {
'card:created': 0x22c55e, // green 'card:created': 0x22c55e, // green
'card:updated': 0x3b82f6, // blue 'card:updated': 0x3b82f6, // blue
@@ -25,39 +39,55 @@ function formatDiscordPayload(event: string, data: any): object {
'board:updated': 0x8b5cf6, // purple 'board:updated': 0x8b5cf6, // purple
}; };
const titles: Record<string, string> = { const cardTitle = data.card?.title || data.cardTitle || 'Unknown';
'card:created': 'Card Created', const cardLink = data.cardUrl && baseUrl ? `[${cardTitle}](${baseUrl}${data.cardUrl})` : cardTitle;
'card:updated': 'Card Updated',
'card:moved': 'Card Moved',
'card:deleted': 'Card Deleted',
'column:created': 'Column Created',
'column:deleted': 'Column Deleted',
'board:updated': 'Board Updated',
};
let description = '';
const fields: { name: string; value: string; inline?: boolean }[] = []; const fields: { name: string; value: string; inline?: boolean }[] = [];
if (data.card) { switch (event) {
fields.push({ name: 'Card', value: data.card.title || data.card.id || 'Unknown', inline: true }); 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 });
} }
if (data.column) { break;
fields.push({ name: 'Column', value: data.column.title || data.column.id || 'Unknown', inline: true });
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 });
} }
if (data.board) { break;
fields.push({ name: 'Board', value: data.board.title || data.board.id || 'Unknown', inline: true });
} case 'card:deleted':
if (event === 'card:deleted') { description = `Deleted: **${cardTitle}**`;
fields.push({ name: 'Card ID', value: data.cardId || 'Unknown', inline: true }); break;
}
if (event === 'column:deleted') { case 'column:created':
fields.push({ name: 'Column ID', value: data.columnId || 'Unknown', inline: true }); 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 { return {
embeds: [{ embeds: [{
title: titles[event] || event, title: event.replace(':', ' ').replace(/\b\w/g, (c: string) => c.toUpperCase()),
description,
color: colors[event] || 0x6b7280, color: colors[event] || 0x6b7280,
fields, fields: fields.length > 0 ? fields : undefined,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
footer: { text: 'Pounce' } footer: { text: 'Pounce' }
}] }]
@@ -73,6 +103,10 @@ async function doDispatch(tenantId: string, event: string, data: any) {
if (webhooks.length === 0) return; 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) { for (const webhook of webhooks) {
const isDiscord = isDiscordWebhook(webhook.url); const isDiscord = isDiscordWebhook(webhook.url);
@@ -80,7 +114,7 @@ async function doDispatch(tenantId: string, event: string, data: any) {
let headers: Record<string, string>; let headers: Record<string, string>;
if (isDiscord) { if (isDiscord) {
body = JSON.stringify(formatDiscordPayload(event, data)); body = JSON.stringify(formatDiscordPayload(event, data, baseUrl));
headers = { 'Content-Type': 'application/json' }; headers = { 'Content-Type': 'application/json' };
} else { } else {
body = JSON.stringify({ event, data, timestamp: new Date().toISOString() }); body = JSON.stringify({ event, data, timestamp: new Date().toISOString() });
@@ -76,7 +76,7 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
const socketId = request.headers.get('X-Socket-ID'); const socketId = request.headers.get('X-Socket-ID');
broadcast(params.boardId, 'card:created', { card, columnId: params.columnId, socketId }); broadcast(params.boardId, 'card:created', { card, columnId: params.columnId, socketId });
broadcast(params.boardId, 'activity:new', { boardId: params.boardId, socketId }); broadcast(params.boardId, 'activity:new', { boardId: params.boardId, socketId });
dispatchWebhooks(tenant.id, 'card:created', { boardId: params.boardId, columnId: params.columnId, card }); dispatchWebhooks(tenant.id, 'card:created', { boardId: params.boardId, columnId: params.columnId, card, cardUrl: `/boards/${params.boardId}?card=${card.id}` });
return json({ card }, { status: 201 }); return json({ card }, { status: 201 });
}; };
@@ -118,7 +118,12 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
} }
const { column: _col, ...cardData } = moved; const { column: _col, ...cardData } = moved;
return cardData; return {
card: cardData,
moveInfo: body.columnId && body.columnId !== oldCard?.columnId
? { from: oldCard?.column.title, to: moved.column.title }
: null
};
} }
// Handle content update // Handle content update
@@ -176,21 +181,34 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
`/boards/${params.boardId}?card=${params.cardId}` `/boards/${params.boardId}?card=${params.cardId}`
); );
return updated; return { card: updated, moveInfo: null };
}); });
const { card: updatedCard, moveInfo } = updated;
const socketId = request.headers.get('X-Socket-ID'); const socketId = request.headers.get('X-Socket-ID');
broadcast(params.boardId, 'card:updated', { card: updated, cardId: params.cardId, columnId: params.columnId, socketId }); broadcast(params.boardId, 'card:updated', { card: updatedCard, cardId: params.cardId, columnId: params.columnId, socketId });
broadcast(params.boardId, 'activity:new', { boardId: params.boardId, cardId: params.cardId, socketId }); broadcast(params.boardId, 'activity:new', { boardId: params.boardId, cardId: params.cardId, socketId });
const cardUrl = `/boards/${params.boardId}?card=${params.cardId}`;
// Dispatch webhook — use card:moved if column changed, otherwise card:updated // Dispatch webhook — use card:moved if column changed, otherwise card:updated
if (body.columnId !== undefined) { if (moveInfo) {
dispatchWebhooks(tenant.id, 'card:moved', { boardId: params.boardId, cardId: params.cardId, card: updated }); dispatchWebhooks(tenant.id, 'card:moved', {
boardId: params.boardId, cardId: params.cardId, card: updatedCard,
from: moveInfo.from, to: moveInfo.to, cardUrl
});
} else { } else {
dispatchWebhooks(tenant.id, 'card:updated', { boardId: params.boardId, cardId: params.cardId, card: updated }); const fields = body.archived !== undefined
? (body.archived ? ['archived'] : ['unarchived'])
: Object.keys(body);
dispatchWebhooks(tenant.id, 'card:updated', {
boardId: params.boardId, cardId: params.cardId, card: updatedCard,
fields, cardUrl
});
} }
return json({ card: updated }); return json({ card: updatedCard });
}; };
// Delete card // Delete card
@@ -198,7 +216,7 @@ export const DELETE: RequestHandler = async ({ params, request, locals }) => {
const tenant = locals.tenant; const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant'); if (!tenant) throw error(400, 'Unknown tenant');
await withTenant(tenant.id, async (tx) => { const deletedTitle = await withTenant(tenant.id, async (tx) => {
const card = await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id); const card = await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id);
await logActivity(tx, { await logActivity(tx, {
@@ -209,12 +227,13 @@ export const DELETE: RequestHandler = async ({ params, request, locals }) => {
}); });
await tx.card.delete({ where: { id: params.cardId } }); await tx.card.delete({ where: { id: params.cardId } });
return card.title;
}); });
const socketId = request.headers.get('X-Socket-ID'); const socketId = request.headers.get('X-Socket-ID');
broadcast(params.boardId, 'card:deleted', { cardId: params.cardId, columnId: params.columnId, socketId }); broadcast(params.boardId, 'card:deleted', { cardId: params.cardId, columnId: params.columnId, socketId });
broadcast(params.boardId, 'activity:new', { boardId: params.boardId, socketId }); broadcast(params.boardId, 'activity:new', { boardId: params.boardId, socketId });
dispatchWebhooks(tenant.id, 'card:deleted', { boardId: params.boardId, cardId: params.cardId, columnId: params.columnId }); dispatchWebhooks(tenant.id, 'card:deleted', { boardId: params.boardId, cardId: params.cardId, columnId: params.columnId, cardTitle: deletedTitle });
return json({ ok: true }); return json({ ok: true });
}; };