brackt/app/services/discord.ts
Claude aea71a5ad6
Some checks failed
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m57s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Failing after 47s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Fix code review findings for QP Discord notifications
- Round qpEarned and qpTotal with Math.round() so fractional tie-split
  values display as integers ("+2 QP" not "+1.5 QP")
- Swap embed colors: standings → gold (0xffd700), QP → blurple (0x5865f2)
- Add logger.error in cs2-major-stage.ts to match scoring-calculator.ts
  error handling pattern (replaces silent .catch(() => {}))
- Fix cumulative CS2 re-announcement: snapshot existing QP before writing
  and only notify participants whose QP is new or changed in this call;
  add participantIdFilter parameter to notifyQualifyingPointsUpdate
- Batch all pre-loop queries in notifyQualifyingPointsUpdate (seasons,
  draftPicks, participantNames, users, Discord IDs) to eliminate N+1
  queries across league iterations
- Add unit tests for notifyQualifyingPointsUpdate covering early-return
  paths, per-league filtering, Discord ID resolution, and participantIdFilter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnJP1NTYXHxL2kbE4fZaD6
2026-07-01 16:28:08 +00:00

471 lines
14 KiB
TypeScript

import { buildTiedRankChecker } from "~/lib/standings-display";
interface DiscordEmbed {
title?: string;
url?: string;
description?: string;
color?: number;
footer?: { text: string };
}
interface DiscordWebhookPayload {
content?: string;
embeds?: DiscordEmbed[];
allowed_mentions?: { parse: string[]; users: string[] };
}
// Per-league serial promise chain: serializes pick notifications within a league to
// respect Discord's per-webhook rate limit (~5 req/2s) during autodraft chains.
const leagueChains = new Map<string, Promise<void>>();
export function enqueuePickNotification(leagueId: string, fn: () => Promise<void>): void {
const prev = leagueChains.get(leagueId) ?? Promise.resolve();
const next = prev.then(() => fn().catch(() => {}));
leagueChains.set(leagueId, next);
// Clean up the map entry once the chain settles, but only if no newer pick
// was enqueued after this one (reference equality guards against that race).
next.then(() => { if (leagueChains.get(leagueId) === next) leagueChains.delete(leagueId); });
}
export async function sendDiscordWebhook(
webhookUrl: string,
payload: DiscordWebhookPayload
): Promise<void> {
const body = JSON.stringify(payload);
const headers = { "Content-Type": "application/json" };
for (let attempt = 0; attempt < 3; attempt++) {
const res = await fetch(webhookUrl, { method: "POST", headers, body });
if (res.status === 429) {
// Cap the total wait at 10 s so awaited callers (e.g. settings actions) are
// never hung for a full Discord global-rate-limit window. Use || 1 instead of
// ?? 1 so NaN (non-numeric header) and 0 both fall back to a 1-second default.
// Multiply by (attempt + 1) for progressive backoff, then cap the product.
const wait = Math.min((Number(res.headers.get("Retry-After")) || 1) * (attempt + 1), 10);
await new Promise((r) => setTimeout(r, wait * 1000));
continue;
}
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Discord webhook failed: ${res.status} ${text}`);
}
return;
}
throw new Error("Discord webhook failed after 3 retries (rate limited)");
}
/** Escape characters that trigger Discord markdown formatting. */
function escapeMarkdown(text: string): string {
return text.replace(/[_*~`|\\]/g, "\\$&");
}
export interface StandingEntry {
teamId: string;
teamName: string;
username?: string;
discordUserId?: string;
totalPoints: number;
rank: number;
}
export interface ScoredMatch {
winnerName: string;
loserName: string;
winnerUsername?: string;
loserUsername?: string;
winnerDiscordUserId?: string;
loserDiscordUserId?: string;
}
export interface EliminatedTeam {
participantName: string;
username?: string;
discordUserId?: string;
}
export async function sendStandingsUpdateNotification({
webhookUrl,
seasonName,
standings,
previousStandings,
previousRanks,
sportName,
eventName,
scoredMatches,
eliminatedTeams,
}: {
webhookUrl: string;
seasonName: string;
standings: StandingEntry[];
previousStandings: Map<string, number>;
previousRanks?: Map<string, number>;
sportName?: string;
eventName?: string;
scoredMatches?: ScoredMatch[];
eliminatedTeams?: EliminatedTeam[];
}): 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 — only show matches where at least one manager
// (fantasy team owner) scored Brackt points or had a team eliminated.
const relevantMatches = scoredMatches?.filter(
(m) => m.winnerUsername !== undefined || m.loserUsername !== undefined
);
if (relevantMatches && relevantMatches.length > 0) {
sections.push("\n**Scored Matches**");
for (const match of relevantMatches) {
const winnerManagerLabel = match.winnerDiscordUserId
? `<@${match.winnerDiscordUserId}>`
: match.winnerUsername
? escapeMarkdown(match.winnerUsername)
: undefined;
const loserManagerLabel = match.loserDiscordUserId
? `<@${match.loserDiscordUserId}>`
: match.loserUsername
? escapeMarkdown(match.loserUsername)
: undefined;
const winnerLabel = winnerManagerLabel
? `${escapeMarkdown(match.winnerName)} (${winnerManagerLabel})`
: escapeMarkdown(match.winnerName);
const loserLabel = loserManagerLabel
? `${escapeMarkdown(match.loserName)} (${loserManagerLabel})`
: escapeMarkdown(match.loserName);
sections.push(`• **${winnerLabel}** def. ${loserLabel}`);
}
}
// Eliminated teams section — drafted teams knocked out when a bracket/knockout
// was generated (e.g. NBA teams that missed the playoffs, FIFA group-stage
// losers). These produce no score change, so they wouldn't appear above.
if (eliminatedTeams && eliminatedTeams.length > 0) {
sections.push("\n**Eliminated**");
for (const team of eliminatedTeams) {
const managerLabel = team.discordUserId
? `<@${team.discordUserId}>`
: team.username
? escapeMarkdown(team.username)
: undefined;
const label = managerLabel
? `${escapeMarkdown(team.participantName)} (${managerLabel})`
: escapeMarkdown(team.participantName);
sections.push(`• **${label}**`);
}
}
const isTied = buildTiedRankChecker(standings.map((s) => s.rank));
const rankLabel = (rank: number) => (isTied(rank) ? `T${rank}` : `${rank}`);
// Standings changes section — show teams whose points or rank changed.
const changedTeams = standings.filter((s) => {
const prevPoints = previousStandings.get(s.teamId);
const pointsChanged = prevPoints !== undefined && prevPoints !== s.totalPoints;
const prevRank = previousRanks?.get(s.teamId);
const rankChanged = prevRank !== undefined && prevRank !== s.rank;
return pointsChanged || rankChanged;
});
if (changedTeams.length > 0) {
sections.push("\n**Standings Changes**");
for (const s of changedTeams) {
const rankPrefix = rankLabel(s.rank);
const prevPoints = previousStandings.get(s.teamId);
let pointDelta = "";
if (prevPoints !== undefined && prevPoints !== s.totalPoints) {
const diff = Math.round(s.totalPoints - prevPoints);
const sign = diff > 0 ? "+" : "";
pointDelta = ` **(${sign}${diff} pts)**`;
}
let rankDelta = "";
if (previousRanks) {
const prevRank = previousRanks.get(s.teamId);
if (prevRank !== undefined && prevRank !== s.rank) {
const moved = prevRank - s.rank; // positive = moved up
rankDelta = moved > 0 ? `${moved}` : `${Math.abs(moved)}`;
}
}
const escapedName = escapeMarkdown(s.teamName);
const managerLabel = s.discordUserId
? `<@${s.discordUserId}>`
: s.username
? escapeMarkdown(s.username)
: undefined;
const label = managerLabel ? `${escapedName} (${managerLabel})` : escapedName;
sections.push(`${rankPrefix}\\. ${label}${Math.round(s.totalPoints)} pts${pointDelta}${rankDelta}`);
}
}
const MAX_DESCRIPTION = 4096;
let description = sections.join("\n");
if (description.length > MAX_DESCRIPTION) {
description = description.slice(0, MAX_DESCRIPTION - 3) + "...";
}
// Collect Discord user IDs of all opted-in managers appearing in this notification.
const pingUserIds = new Set<string>();
for (const s of changedTeams) {
if (s.discordUserId) pingUserIds.add(s.discordUserId);
}
for (const m of relevantMatches ?? []) {
if (m.winnerDiscordUserId) pingUserIds.add(m.winnerDiscordUserId);
if (m.loserDiscordUserId) pingUserIds.add(m.loserDiscordUserId);
}
for (const t of eliminatedTeams ?? []) {
if (t.discordUserId) pingUserIds.add(t.discordUserId);
}
const pingIds = [...pingUserIds];
const payload: DiscordWebhookPayload = {
embeds: [
{
title: `📊 Standings Update — ${seasonName}`,
description,
color: 0xffd700, // Gold
footer: { text: "brackt.com" },
},
],
};
if (pingIds.length > 0) {
// Discord caps allowed_mentions.users at 100; slice to avoid a rejected payload.
const cappedIds = pingIds.slice(0, 100);
payload.content = cappedIds.map((id) => `<@${id}>`).join(" ");
payload.allowed_mentions = { parse: [], users: cappedIds };
}
await sendDiscordWebhook(webhookUrl, payload);
}
export interface QPEventEntry {
participantName: string;
qpEarned: number;
qpTotal: number;
ownerUsername?: string;
ownerDiscordUserId?: string;
}
export async function sendQualifyingPointsUpdateNotification({
webhookUrl,
seasonName,
sportName,
eventName,
entries,
}: {
webhookUrl: string;
seasonName: string;
sportName?: string;
eventName?: string;
entries: QPEventEntry[];
}): Promise<void> {
if (entries.length === 0) return;
const sections: string[] = [];
if (sportName || eventName) {
const parts = [sportName, eventName].filter(Boolean);
sections.push(`**${parts.join(" — ")}**`);
}
const awardedEntries = entries
.filter((e) => e.qpEarned > 0)
.toSorted((a, b) => b.qpEarned - a.qpEarned);
if (awardedEntries.length > 0) {
sections.push("\n**Points Awarded**");
for (const e of awardedEntries) {
const managerLabel = e.ownerDiscordUserId
? `<@${e.ownerDiscordUserId}>`
: e.ownerUsername
? escapeMarkdown(e.ownerUsername)
: undefined;
const label = managerLabel
? `${escapeMarkdown(e.participantName)} (${managerLabel})`
: escapeMarkdown(e.participantName);
sections.push(`• **${label}** — +${Math.round(e.qpEarned)} QP`);
}
}
const zeroEntries = entries.filter((e) => e.qpEarned === 0);
if (zeroEntries.length > 0) {
sections.push("\n**No Points**");
for (const e of zeroEntries) {
const managerLabel = e.ownerDiscordUserId
? `<@${e.ownerDiscordUserId}>`
: e.ownerUsername
? escapeMarkdown(e.ownerUsername)
: undefined;
const label = managerLabel
? `${escapeMarkdown(e.participantName)} (${managerLabel})`
: escapeMarkdown(e.participantName);
sections.push(`${label}`);
}
}
const sorted = [...entries].toSorted((a, b) => b.qpTotal - a.qpTotal);
// Compute ranks with tie detection
const ranks: number[] = [];
for (let i = 0; i < sorted.length; i++) {
if (i > 0 && sorted[i].qpTotal === sorted[i - 1].qpTotal) {
ranks.push(ranks[ranks.length - 1]);
} else {
ranks.push(i + 1);
}
}
const isTied = buildTiedRankChecker(ranks);
sections.push("\n**QP Standings**");
for (let i = 0; i < sorted.length; i++) {
const e = sorted[i];
const r = ranks[i];
const rankPrefix = isTied(r) ? `T${r}` : `${r}`;
const ownerLabel = e.ownerDiscordUserId
? `<@${e.ownerDiscordUserId}>`
: e.ownerUsername
? escapeMarkdown(e.ownerUsername)
: undefined;
const managerLabel = ownerLabel ? ` (${ownerLabel})` : "";
sections.push(`${rankPrefix}\\. ${escapeMarkdown(e.participantName)}${managerLabel}${Math.round(e.qpTotal)} QP`);
}
const MAX_DESCRIPTION = 4096;
let description = sections.join("\n");
if (description.length > MAX_DESCRIPTION) {
description = description.slice(0, MAX_DESCRIPTION - 3) + "...";
}
const pingUserIds = new Set<string>();
for (const e of [...awardedEntries, ...zeroEntries]) {
if (e.ownerDiscordUserId) pingUserIds.add(e.ownerDiscordUserId);
}
const pingIds = [...pingUserIds];
const payload: DiscordWebhookPayload = {
embeds: [
{
title: `🏅 Qualifying Points Update — ${seasonName}`,
description,
color: 0x5865f2, // Discord blurple
footer: { text: "brackt.com" },
},
],
};
if (pingIds.length > 0) {
const cappedIds = pingIds.slice(0, 100);
payload.content = cappedIds.map((id) => `<@${id}>`).join(" ");
payload.allowed_mentions = { parse: [], users: cappedIds };
}
await sendDiscordWebhook(webhookUrl, payload);
}
export async function sendPickAnnouncementNotification({
webhookUrl,
draftUrl,
pickNumber,
round,
pickInRound,
pickedTeamName,
participantName,
sportName,
nextTeamName,
nextOwnerDiscordId,
isDraftComplete,
}: {
webhookUrl: string;
draftUrl: string;
pickNumber: number;
round: number;
pickInRound: number;
pickedTeamName: string;
participantName: string;
sportName: string;
nextTeamName?: string;
nextOwnerDiscordId?: string;
isDraftComplete: boolean;
}): Promise<void> {
const lines: string[] = [
`**${escapeMarkdown(pickedTeamName)}** selected **${escapeMarkdown(participantName)}** (${escapeMarkdown(sportName)})`,
];
if (isDraftComplete) {
lines.push("", "The draft is complete!");
} else if (nextTeamName) {
const nextLabel = nextOwnerDiscordId
? `<@${nextOwnerDiscordId}>`
: escapeMarkdown(nextTeamName);
lines.push("", `On the clock: **${escapeMarkdown(nextTeamName)}** (${nextLabel})`);
}
const description = lines.join("\n");
const payload: DiscordWebhookPayload = {
embeds: [
{
title: `Pick #${pickNumber} — Round ${round}, Pick ${pickInRound}`,
url: draftUrl,
description,
color: 0x5865f2,
},
],
};
if (nextOwnerDiscordId && !isDraftComplete) {
payload.content = `<@${nextOwnerDiscordId}>`;
payload.allowed_mentions = { parse: [], users: [nextOwnerDiscordId] };
}
await sendDiscordWebhook(webhookUrl, payload);
}
export async function sendDraftOrderNotification({
webhookUrl,
leagueName,
leagueUrl,
method,
teams,
}: {
webhookUrl: string;
leagueName: string;
leagueUrl: string;
method: "manual" | "randomized";
teams: Array<{ name: string; position: number; username?: string }>;
}): Promise<void> {
const title =
method === "manual"
? `Draft Order Manually Set — ${leagueName}`
: `Draft Order Randomized — ${leagueName}`;
const sorted = teams.toSorted((a, b) => a.position - b.position);
let description = sorted
.map((t) => {
const teamLabel = escapeMarkdown(t.name);
const usernameLabel = t.username ? ` (${escapeMarkdown(t.username)})` : "";
return `${t.position}. ${teamLabel}${usernameLabel}`;
})
.join("\n");
if (description.length > 4096) {
description = description.slice(0, 4093) + "...";
}
await sendDiscordWebhook(webhookUrl, {
embeds: [
{
title,
url: leagueUrl,
description,
color: 0x5865f2,
},
],
});
}