From 44e5af6c2b91035b2b6dbc56fb3e5cdfbb13cf1d Mon Sep 17 00:00:00 2001 From: Catherine Renelle <32781029+catrenelle@users.noreply.github.com> Date: Sun, 8 Mar 2026 11:05:17 -0400 Subject: [PATCH] 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 --- src/lib/server/webhooks.ts | 88 +++++++++++++------ .../columns/[columnId]/cards/+server.ts | 2 +- .../[columnId]/cards/[cardId]/+server.ts | 37 ++++++-- 3 files changed, 90 insertions(+), 37 deletions(-) diff --git a/src/lib/server/webhooks.ts b/src/lib/server/webhooks.ts index f5d7b36..b95b6cd 100644 --- a/src/lib/server/webhooks.ts +++ b/src/lib/server/webhooks.ts @@ -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 { + 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 = { '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 = { - '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; 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() }); diff --git a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/+server.ts b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/+server.ts index 7ee1ba7..5415c45 100644 --- a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/+server.ts +++ b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/+server.ts @@ -76,7 +76,7 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { const socketId = request.headers.get('X-Socket-ID'); broadcast(params.boardId, 'card:created', { card, columnId: params.columnId, 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 }); }; diff --git a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/+server.ts b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/+server.ts index d76b871..a5685e8 100644 --- a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/+server.ts +++ b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/+server.ts @@ -118,7 +118,12 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => { } 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 @@ -176,21 +181,34 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => { `/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'); - 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 }); + const cardUrl = `/boards/${params.boardId}?card=${params.cardId}`; + // Dispatch webhook — use card:moved if column changed, otherwise card:updated - if (body.columnId !== undefined) { - dispatchWebhooks(tenant.id, 'card:moved', { boardId: params.boardId, cardId: params.cardId, card: updated }); + if (moveInfo) { + dispatchWebhooks(tenant.id, 'card:moved', { + boardId: params.boardId, cardId: params.cardId, card: updatedCard, + from: moveInfo.from, to: moveInfo.to, cardUrl + }); } 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 @@ -198,7 +216,7 @@ export const DELETE: RequestHandler = async ({ params, request, locals }) => { const tenant = locals.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); await logActivity(tx, { @@ -209,12 +227,13 @@ export const DELETE: RequestHandler = async ({ params, request, locals }) => { }); await tx.card.delete({ where: { id: params.cardId } }); + return card.title; }); const socketId = request.headers.get('X-Socket-ID'); broadcast(params.boardId, 'card:deleted', { cardId: params.cardId, columnId: params.columnId, 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 }); };