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/');
|
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}`;
|
||||||
if (data.column) {
|
break;
|
||||||
fields.push({ name: 'Column', value: data.column.title || data.column.id || 'Unknown', inline: true });
|
|
||||||
}
|
case 'card:updated':
|
||||||
if (data.board) {
|
description = `Updated: ${cardLink}`;
|
||||||
fields.push({ name: 'Board', value: data.board.title || data.board.id || 'Unknown', inline: true });
|
if (data.fields?.length) {
|
||||||
}
|
fields.push({ name: 'Changed', value: data.fields.join(', '), inline: true });
|
||||||
if (event === 'card:deleted') {
|
}
|
||||||
fields.push({ name: 'Card ID', value: data.cardId || 'Unknown', inline: true });
|
break;
|
||||||
}
|
|
||||||
if (event === 'column:deleted') {
|
case 'card:moved':
|
||||||
fields.push({ name: 'Column ID', value: data.columnId || 'Unknown', inline: true });
|
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 {
|
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 });
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user