brackt/app/services/discord.ts

147 lines
4.5 KiB
TypeScript
Raw Normal View History

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}`);
}
}
/** Escape characters that trigger Discord markdown formatting. */
function escapeMarkdown(text: string): string {
return text.replace(/[_*~`|\\]/g, "\\$&");
}
export interface StandingEntry {
Partial bracket scoring, code review fixes, and double-chance logic (#156) ## Partial bracket scoring - `processMatchResult`: new exported function that scores a single match immediately (loser → final placement, winner → provisional floor). Called from `set-winner` and `set-round-winners` so points are awarded as soon as a winner is set, before the full round is complete. - `set-winner`: passes `eventName` to `processMatchResult`. - `set-round-winners`: batches side effects — calls `recalculateAffectedLeagues` and `updateProbabilitiesAfterResult` once after the loop instead of per-match (`skipSideEffects: true` per match). ## Code review fixes - **ROUND_CONFIG** (S1): extracted a `RoundScoringConfig` lookup table + `getRoundConfig()` helper, eliminating three parallel `if/else` chains in `processPlayoffEvent`, `processMatchResult`, and `getGuaranteedMinimumPosition`. AFL template overrides live in `TEMPLATE_ROUND_CONFIG` with an explanatory comment about why the `bracketTemplateId` guard is required. - **skipSideEffects** (C2): new param on `processMatchResult`; bracket server uses it to batch standings/probability recalc in `set-round-winners`. - **eventName** (W1): passed through `processMatchResult` → `recalculateAffectedLeagues`. - **Round validation** (W2): `set-round-winners` now guards `match.round === round` before processing each assignment. - **Comments** (C3/S3/W3): added notes on non-scoring loser assumption, `isScoring ?? true` default, and AFL Semi-Finals template requirement. ## PlayoffBracket eliminated-teams fix + tests - Fixed `computeEliminatedByRound` to track participant *appearances* (not just wins), so AFL QF losers who advance to Semi-Finals via double-chance are correctly excluded from the QF eliminated list. - Extracted the logic as an exported pure function for testability. - Added 4 tests: standard elimination, AFL QF loser → SF win, AFL QF loser → SF loss, and normal advancement not protecting a later loser. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 10:50:30 -07:00
teamId: string;
teamName: string;
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
username?: string;
Partial bracket scoring, code review fixes, and double-chance logic (#156) ## Partial bracket scoring - `processMatchResult`: new exported function that scores a single match immediately (loser → final placement, winner → provisional floor). Called from `set-winner` and `set-round-winners` so points are awarded as soon as a winner is set, before the full round is complete. - `set-winner`: passes `eventName` to `processMatchResult`. - `set-round-winners`: batches side effects — calls `recalculateAffectedLeagues` and `updateProbabilitiesAfterResult` once after the loop instead of per-match (`skipSideEffects: true` per match). ## Code review fixes - **ROUND_CONFIG** (S1): extracted a `RoundScoringConfig` lookup table + `getRoundConfig()` helper, eliminating three parallel `if/else` chains in `processPlayoffEvent`, `processMatchResult`, and `getGuaranteedMinimumPosition`. AFL template overrides live in `TEMPLATE_ROUND_CONFIG` with an explanatory comment about why the `bracketTemplateId` guard is required. - **skipSideEffects** (C2): new param on `processMatchResult`; bracket server uses it to batch standings/probability recalc in `set-round-winners`. - **eventName** (W1): passed through `processMatchResult` → `recalculateAffectedLeagues`. - **Round validation** (W2): `set-round-winners` now guards `match.round === round` before processing each assignment. - **Comments** (C3/S3/W3): added notes on non-scoring loser assumption, `isScoring ?? true` default, and AFL Semi-Finals template requirement. ## PlayoffBracket eliminated-teams fix + tests - Fixed `computeEliminatedByRound` to track participant *appearances* (not just wins), so AFL QF losers who advance to Semi-Finals via double-chance are correctly excluded from the QF eliminated list. - Extracted the logic as an exported pure function for testability. - Added 4 tests: standard elimination, AFL QF loser → SF win, AFL QF loser → SF loss, and normal advancement not protecting a later loser. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 10:50:30 -07:00
totalPoints: number;
rank: number;
Partial bracket scoring, code review fixes, and double-chance logic (#156) ## Partial bracket scoring - `processMatchResult`: new exported function that scores a single match immediately (loser → final placement, winner → provisional floor). Called from `set-winner` and `set-round-winners` so points are awarded as soon as a winner is set, before the full round is complete. - `set-winner`: passes `eventName` to `processMatchResult`. - `set-round-winners`: batches side effects — calls `recalculateAffectedLeagues` and `updateProbabilitiesAfterResult` once after the loop instead of per-match (`skipSideEffects: true` per match). ## Code review fixes - **ROUND_CONFIG** (S1): extracted a `RoundScoringConfig` lookup table + `getRoundConfig()` helper, eliminating three parallel `if/else` chains in `processPlayoffEvent`, `processMatchResult`, and `getGuaranteedMinimumPosition`. AFL template overrides live in `TEMPLATE_ROUND_CONFIG` with an explanatory comment about why the `bracketTemplateId` guard is required. - **skipSideEffects** (C2): new param on `processMatchResult`; bracket server uses it to batch standings/probability recalc in `set-round-winners`. - **eventName** (W1): passed through `processMatchResult` → `recalculateAffectedLeagues`. - **Round validation** (W2): `set-round-winners` now guards `match.round === round` before processing each assignment. - **Comments** (C3/S3/W3): added notes on non-scoring loser assumption, `isScoring ?? true` default, and AFL Semi-Finals template requirement. ## PlayoffBracket eliminated-teams fix + tests - Fixed `computeEliminatedByRound` to track participant *appearances* (not just wins), so AFL QF losers who advance to Semi-Finals via double-chance are correctly excluded from the QF eliminated list. - Extracted the logic as an exported pure function for testability. - Added 4 tests: standard elimination, AFL QF loser → SF win, AFL QF loser → SF loss, and normal advancement not protecting a later loser. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 10:50:30 -07:00
}
export interface ScoredMatch {
winnerName: string;
loserName: string;
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
winnerUsername?: string;
loserUsername?: string;
Partial bracket scoring, code review fixes, and double-chance logic (#156) ## Partial bracket scoring - `processMatchResult`: new exported function that scores a single match immediately (loser → final placement, winner → provisional floor). Called from `set-winner` and `set-round-winners` so points are awarded as soon as a winner is set, before the full round is complete. - `set-winner`: passes `eventName` to `processMatchResult`. - `set-round-winners`: batches side effects — calls `recalculateAffectedLeagues` and `updateProbabilitiesAfterResult` once after the loop instead of per-match (`skipSideEffects: true` per match). ## Code review fixes - **ROUND_CONFIG** (S1): extracted a `RoundScoringConfig` lookup table + `getRoundConfig()` helper, eliminating three parallel `if/else` chains in `processPlayoffEvent`, `processMatchResult`, and `getGuaranteedMinimumPosition`. AFL template overrides live in `TEMPLATE_ROUND_CONFIG` with an explanatory comment about why the `bracketTemplateId` guard is required. - **skipSideEffects** (C2): new param on `processMatchResult`; bracket server uses it to batch standings/probability recalc in `set-round-winners`. - **eventName** (W1): passed through `processMatchResult` → `recalculateAffectedLeagues`. - **Round validation** (W2): `set-round-winners` now guards `match.round === round` before processing each assignment. - **Comments** (C3/S3/W3): added notes on non-scoring loser assumption, `isScoring ?? true` default, and AFL Semi-Finals template requirement. ## PlayoffBracket eliminated-teams fix + tests - Fixed `computeEliminatedByRound` to track participant *appearances* (not just wins), so AFL QF losers who advance to Semi-Finals via double-chance are correctly excluded from the QF eliminated list. - Extracted the logic as an exported pure function for testability. - Added 4 tests: standard elimination, AFL QF loser → SF win, AFL QF loser → SF loss, and normal advancement not protecting a later loser. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 10:50:30 -07:00
}
export async function sendStandingsUpdateNotification({
webhookUrl,
seasonName,
standings,
previousStandings,
previousRanks,
sportName,
Partial bracket scoring, code review fixes, and double-chance logic (#156) ## Partial bracket scoring - `processMatchResult`: new exported function that scores a single match immediately (loser → final placement, winner → provisional floor). Called from `set-winner` and `set-round-winners` so points are awarded as soon as a winner is set, before the full round is complete. - `set-winner`: passes `eventName` to `processMatchResult`. - `set-round-winners`: batches side effects — calls `recalculateAffectedLeagues` and `updateProbabilitiesAfterResult` once after the loop instead of per-match (`skipSideEffects: true` per match). ## Code review fixes - **ROUND_CONFIG** (S1): extracted a `RoundScoringConfig` lookup table + `getRoundConfig()` helper, eliminating three parallel `if/else` chains in `processPlayoffEvent`, `processMatchResult`, and `getGuaranteedMinimumPosition`. AFL template overrides live in `TEMPLATE_ROUND_CONFIG` with an explanatory comment about why the `bracketTemplateId` guard is required. - **skipSideEffects** (C2): new param on `processMatchResult`; bracket server uses it to batch standings/probability recalc in `set-round-winners`. - **eventName** (W1): passed through `processMatchResult` → `recalculateAffectedLeagues`. - **Round validation** (W2): `set-round-winners` now guards `match.round === round` before processing each assignment. - **Comments** (C3/S3/W3): added notes on non-scoring loser assumption, `isScoring ?? true` default, and AFL Semi-Finals template requirement. ## PlayoffBracket eliminated-teams fix + tests - Fixed `computeEliminatedByRound` to track participant *appearances* (not just wins), so AFL QF losers who advance to Semi-Finals via double-chance are correctly excluded from the QF eliminated list. - Extracted the logic as an exported pure function for testability. - Added 4 tests: standard elimination, AFL QF loser → SF win, AFL QF loser → SF loss, and normal advancement not protecting a later loser. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 10:50:30 -07:00
eventName,
scoredMatches,
}: {
webhookUrl: string;
seasonName: string;
standings: StandingEntry[];
previousStandings: Map<string, number>;
previousRanks?: Map<string, number>;
sportName?: string;
eventName?: string;
scoredMatches?: ScoredMatch[];
}): Promise<void> {
const sections: string[] = [];
Partial bracket scoring, code review fixes, and double-chance logic (#156) ## Partial bracket scoring - `processMatchResult`: new exported function that scores a single match immediately (loser → final placement, winner → provisional floor). Called from `set-winner` and `set-round-winners` so points are awarded as soon as a winner is set, before the full round is complete. - `set-winner`: passes `eventName` to `processMatchResult`. - `set-round-winners`: batches side effects — calls `recalculateAffectedLeagues` and `updateProbabilitiesAfterResult` once after the loop instead of per-match (`skipSideEffects: true` per match). ## Code review fixes - **ROUND_CONFIG** (S1): extracted a `RoundScoringConfig` lookup table + `getRoundConfig()` helper, eliminating three parallel `if/else` chains in `processPlayoffEvent`, `processMatchResult`, and `getGuaranteedMinimumPosition`. AFL template overrides live in `TEMPLATE_ROUND_CONFIG` with an explanatory comment about why the `bracketTemplateId` guard is required. - **skipSideEffects** (C2): new param on `processMatchResult`; bracket server uses it to batch standings/probability recalc in `set-round-winners`. - **eventName** (W1): passed through `processMatchResult` → `recalculateAffectedLeagues`. - **Round validation** (W2): `set-round-winners` now guards `match.round === round` before processing each assignment. - **Comments** (C3/S3/W3): added notes on non-scoring loser assumption, `isScoring ?? true` default, and AFL Semi-Finals template requirement. ## PlayoffBracket eliminated-teams fix + tests - Fixed `computeEliminatedByRound` to track participant *appearances* (not just wins), so AFL QF losers who advance to Semi-Finals via double-chance are correctly excluded from the QF eliminated list. - Extracted the logic as an exported pure function for testability. - Added 4 tests: standard elimination, AFL QF loser → SF win, AFL QF loser → SF loss, and normal advancement not protecting a later loser. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 10:50:30 -07:00
// Header: "Sport Name — Event Name"
if (sportName || eventName) {
const parts = [sportName, eventName].filter(Boolean);
sections.push(`**${parts.join(" — ")}**`);
Partial bracket scoring, code review fixes, and double-chance logic (#156) ## Partial bracket scoring - `processMatchResult`: new exported function that scores a single match immediately (loser → final placement, winner → provisional floor). Called from `set-winner` and `set-round-winners` so points are awarded as soon as a winner is set, before the full round is complete. - `set-winner`: passes `eventName` to `processMatchResult`. - `set-round-winners`: batches side effects — calls `recalculateAffectedLeagues` and `updateProbabilitiesAfterResult` once after the loop instead of per-match (`skipSideEffects: true` per match). ## Code review fixes - **ROUND_CONFIG** (S1): extracted a `RoundScoringConfig` lookup table + `getRoundConfig()` helper, eliminating three parallel `if/else` chains in `processPlayoffEvent`, `processMatchResult`, and `getGuaranteedMinimumPosition`. AFL template overrides live in `TEMPLATE_ROUND_CONFIG` with an explanatory comment about why the `bracketTemplateId` guard is required. - **skipSideEffects** (C2): new param on `processMatchResult`; bracket server uses it to batch standings/probability recalc in `set-round-winners`. - **eventName** (W1): passed through `processMatchResult` → `recalculateAffectedLeagues`. - **Round validation** (W2): `set-round-winners` now guards `match.round === round` before processing each assignment. - **Comments** (C3/S3/W3): added notes on non-scoring loser assumption, `isScoring ?? true` default, and AFL Semi-Finals template requirement. ## PlayoffBracket eliminated-teams fix + tests - Fixed `computeEliminatedByRound` to track participant *appearances* (not just wins), so AFL QF losers who advance to Semi-Finals via double-chance are correctly excluded from the QF eliminated list. - Extracted the logic as an exported pure function for testability. - Added 4 tests: standard elimination, AFL QF loser → SF win, AFL QF loser → SF loss, and normal advancement not protecting a later loser. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 10:50:30 -07:00
}
// 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 winnerLabel = match.winnerUsername
? `${escapeMarkdown(match.winnerName)} (${escapeMarkdown(match.winnerUsername)})`
: escapeMarkdown(match.winnerName);
const loserLabel = match.loserUsername
? `${escapeMarkdown(match.loserName)} (${escapeMarkdown(match.loserUsername)})`
: escapeMarkdown(match.loserName);
sections.push(`• **${winnerLabel}** def. ${loserLabel}`);
}
}
// 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 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 escapedUsername = s.username ? escapeMarkdown(s.username) : undefined;
const label = escapedUsername ? `${escapedName} (${escapedUsername})` : escapedName;
sections.push(`${s.rank}. ${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) + "...";
}
await sendDiscordWebhook(webhookUrl, {
embeds: [
{
title: `📊 Standings Update — ${seasonName}`,
description,
color: 0x5865f2, // Discord blurple
footer: { text: "brackt.com" },
},
],
});
Partial bracket scoring, code review fixes, and double-chance logic (#156) ## Partial bracket scoring - `processMatchResult`: new exported function that scores a single match immediately (loser → final placement, winner → provisional floor). Called from `set-winner` and `set-round-winners` so points are awarded as soon as a winner is set, before the full round is complete. - `set-winner`: passes `eventName` to `processMatchResult`. - `set-round-winners`: batches side effects — calls `recalculateAffectedLeagues` and `updateProbabilitiesAfterResult` once after the loop instead of per-match (`skipSideEffects: true` per match). ## Code review fixes - **ROUND_CONFIG** (S1): extracted a `RoundScoringConfig` lookup table + `getRoundConfig()` helper, eliminating three parallel `if/else` chains in `processPlayoffEvent`, `processMatchResult`, and `getGuaranteedMinimumPosition`. AFL template overrides live in `TEMPLATE_ROUND_CONFIG` with an explanatory comment about why the `bracketTemplateId` guard is required. - **skipSideEffects** (C2): new param on `processMatchResult`; bracket server uses it to batch standings/probability recalc in `set-round-winners`. - **eventName** (W1): passed through `processMatchResult` → `recalculateAffectedLeagues`. - **Round validation** (W2): `set-round-winners` now guards `match.round === round` before processing each assignment. - **Comments** (C3/S3/W3): added notes on non-scoring loser assumption, `isScoring ?? true` default, and AFL Semi-Finals template requirement. ## PlayoffBracket eliminated-teams fix + tests - Fixed `computeEliminatedByRound` to track participant *appearances* (not just wins), so AFL QF losers who advance to Semi-Finals via double-chance are correctly excluded from the QF eliminated list. - Extracted the logic as an exported pure function for testability. - Added 4 tests: standard elimination, AFL QF loser → SF win, AFL QF loser → SF loss, and normal advancement not protecting a later loser. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 10:50:30 -07:00
}