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:
+61
-27
@@ -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() });
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user