brackt/app/services/discord.ts
Chris Parsons 1f8a69a2b7 Add Discord webhook notifications for standings updates
Adds a Discord webhook integration that posts standings to a configured
Discord channel whenever scores change after a scoring event. Commissioners
set the webhook URL in league settings and can send a test notification.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 11:08:45 -07:00

98 lines
2.4 KiB
TypeScript

interface DiscordEmbed {
title?: string;
description?: string;
color?: number;
footer?: { text: string };
}
interface DiscordWebhookPayload {
content?: string;
embeds?: DiscordEmbed[];
}
export async function sendDiscordWebhook(
webhookUrl: string,
payload: DiscordWebhookPayload
): Promise<void> {
const response = await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(`Discord webhook failed: ${response.status} ${text}`);
}
}
export interface StandingEntry {
teamId: string;
teamName: string;
totalPoints: number;
rank: number;
}
export interface ScoredMatch {
winnerName: string;
loserName: string;
}
export async function sendStandingsUpdateNotification({
webhookUrl,
seasonName,
standings,
previousStandings,
sportName,
eventName,
scoredMatches,
}: {
webhookUrl: string;
seasonName: string;
standings: StandingEntry[];
previousStandings: Map<string, number>;
sportName?: string;
eventName?: string;
scoredMatches?: ScoredMatch[];
}): Promise<void> {
const RANK_MEDALS = ["🥇", "🥈", "🥉"];
const sections: string[] = [];
// Header: "Sport Name — Event Name"
if (sportName || eventName) {
const parts = [sportName, eventName].filter(Boolean);
sections.push(`**${parts.join(" — ")}**`);
}
// Scored matches section
if (scoredMatches && scoredMatches.length > 0) {
sections.push("**Scored Matches**");
for (const match of scoredMatches) {
sections.push(`⚽ **${match.winnerName}** def. ${match.loserName}`);
}
}
// Standings section
sections.push("**Current Standings**");
for (const s of standings) {
const prev = previousStandings.get(s.teamId);
const delta =
prev !== undefined && prev !== s.totalPoints
? ` **(+${Math.round(s.totalPoints - prev)} pts)**`
: "";
const medal = RANK_MEDALS[s.rank - 1] ?? `${s.rank}.`;
sections.push(`${medal} ${s.teamName}${Math.round(s.totalPoints)} pts${delta}`);
}
await sendDiscordWebhook(webhookUrl, {
embeds: [
{
title: `📊 Standings Update — ${seasonName}`,
description: sections.join("\n"),
color: 0x5865f2, // Discord blurple
footer: { text: "brackt.com" },
},
],
});
}