brackt/app/services/discord.ts
Chris Parsons 7236c8aefb
Add Discord usernames, fix calculateTeamScore partial-score bug, and add admin re-score action (#160)
**Discord webhook improvements**
- Show team owner username in parentheses in standings (e.g. "1. Alpha FC (christhrowsrocks) — 150 pts")
- Show username in parentheses in match results for drafted participants
  (e.g. "Sporting (christhrowsrocks) def. Bodø/Glimt (apatel)"), omitting
  the parenthesis for the undrafted side
- Username lookup only runs after the webhook URL guard to avoid wasted DB queries
- 4 new tests covering all username display combinations

**Fix calculateTeamScore partial-score counting bug**
- isPartialScore participants were incorrectly counted in participantsCompleted
  and placementCounts, causing standings to show wrong remaining count and
  phantom placement badges (e.g. "5th×1") for still-alive bracket participants
- Floor points still flow into totalPoints (guaranteed value for ranking)
- 3 new unit tests covering partial vs finalized behavior in calculateTeamScore

**Admin Force Re-score action**
- New "Fantasy Standings" card on every sports season admin page with a
  Force Re-score button that triggers recalculateStandings on all linked
  fantasy seasons — useful after data corrections without waiting for a
  new scoring event
- Auth guard added to the action (was missing — layout loader only protects GET)
- Returns a meaningful message when no linked seasons exist
- Intent discriminant on action returns so success banners use actionData.intent
  instead of fragile message.includes() checks

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 14:34:09 -07:00

113 lines
2.9 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;
username?: string;
totalPoints: number;
rank: number;
}
export interface ScoredMatch {
winnerName: string;
loserName: string;
winnerUsername?: string;
loserUsername?: 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 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("\n**Scored Matches**");
for (const match of scoredMatches) {
const winner = match.winnerUsername
? `${match.winnerName} (${match.winnerUsername})`
: match.winnerName;
const loser = match.loserUsername
? `${match.loserName} (${match.loserUsername})`
: match.loserName;
sections.push(`• **${winner}** def. ${loser}`);
}
}
// Standings section
sections.push("\n**Current Standings**");
for (const s of standings) {
const prev = previousStandings.get(s.teamId);
let delta = "";
if (prev !== undefined && prev !== s.totalPoints) {
const diff = Math.round(s.totalPoints - prev);
const sign = diff > 0 ? "+" : "";
delta = ` **(${sign}${diff} pts)**`;
}
const label = s.username ? `${s.teamName} (${s.username})` : s.teamName;
sections.push(`${s.rank}. ${label}${Math.round(s.totalPoints)} pts${delta}`);
}
const MAX_DESCRIPTION = 4096;
let description = sections.join("\n");
if (description.length > MAX_DESCRIPTION) {
description = description.slice(0, MAX_DESCRIPTION - 3) + "...";
}
await sendDiscordWebhook(webhookUrl, {
embeds: [
{
title: `📊 Standings Update — ${seasonName}`,
description,
color: 0x5865f2, // Discord blurple
footer: { text: "brackt.com" },
},
],
});
}