Add Discord webhook notifications for standings updates (#157)

* 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>

* Fix Discord notification edge cases from code review

- Fix point delta sign: negative diffs now correctly show -5 pts not +-5 pts
- Replace hardcoded soccer emoji with a neutral bullet (works for all sports)
- Truncate embed description at Discord's 4096-char limit
- Thread eventId through processMatchResult and bracket batch handler so
  scored matches appear in single-match notifications too
- Pass eventName to finalizeQualifyingPoints ("Final Standings") and
  processSeasonStandings ("Season Complete") so those notifications have context
- Test notification now fetches current season to show "League 2025" format

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix process-match-result tests: add sportsSeasons to db mock

recalculateAffectedLeagues now queries sportsSeasons to get the sport
name for Discord notifications; the test mock db needed the stub added.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-03-17 11:16:36 -07:00 committed by GitHub
parent dbffddc9ff
commit 79f7a41837
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 421 additions and 57 deletions

View file

@ -46,6 +46,7 @@ function makeDb(existingResult?: { id: string; isPartialScore: boolean }) {
seasonSports: {
findMany: vi.fn().mockResolvedValue([]), // empty → standings loop exits immediately
},
sportsSeasons: { findFirst: vi.fn().mockResolvedValue(null) },
scoringEvents: { findFirst: vi.fn().mockResolvedValue(null) },
},
} as any,

View file

@ -4,7 +4,7 @@ import { eq, and, inArray } from "drizzle-orm";
import { getScoringRules, calculateFantasyPoints, calculateAveragedPoints, calculateBracketPoints } from "./scoring-rules";
import { getSeasonResults } from "./participant-season-result";
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
import { sendStandingsUpdateNotification } from "~/services/discord";
import { sendStandingsUpdateNotification, type ScoredMatch } from "~/services/discord";
/**
* Core scoring calculation engine
@ -274,7 +274,7 @@ export async function processPlayoffEvent(
}
// Recalculate standings for all affected leagues
await recalculateAffectedLeagues(event.sportsSeasonId, db, { eventName: event.name });
await recalculateAffectedLeagues(event.sportsSeasonId, db, { eventName: event.name, eventId });
// Auto-trigger probability recalculation after result
try {
@ -310,13 +310,14 @@ export async function processMatchResult(
isScoring: boolean;
sportsSeasonId: string;
bracketTemplateId?: string | null;
eventId?: string;
eventName?: string;
skipSideEffects?: boolean;
},
providedDb?: ReturnType<typeof database>
): Promise<void> {
const db = providedDb || database();
const { round, winnerId, loserId, isScoring, sportsSeasonId, bracketTemplateId, eventName, skipSideEffects } = params;
const { round, winnerId, loserId, isScoring, sportsSeasonId, bracketTemplateId, eventId, eventName, skipSideEffects } = params;
if (!isScoring) {
// Non-scoring (pre-bracket) round: loser permanently eliminated (0 pts),
@ -346,7 +347,7 @@ export async function processMatchResult(
}
if (!skipSideEffects) {
await recalculateAffectedLeagues(sportsSeasonId, db, eventName ? { eventName } : undefined);
await recalculateAffectedLeagues(sportsSeasonId, db, (eventName || eventId) ? { eventName, eventId } : undefined);
try {
await updateProbabilitiesAfterResult(sportsSeasonId, true);
} catch (error) {
@ -629,7 +630,7 @@ export async function finalizeQualifyingPoints(
.where(eq(schema.sportsSeasons.id, sportsSeasonId));
// Trigger recalculation for all affected leagues
await recalculateAffectedLeagues(sportsSeasonId, db);
await recalculateAffectedLeagues(sportsSeasonId, db, { eventName: "Final Standings" });
// Auto-trigger probability recalculation after result
try {
@ -739,7 +740,7 @@ export async function processSeasonStandings(
}
// Trigger recalculation for all affected leagues
await recalculateAffectedLeagues(sportsSeasonId, db);
await recalculateAffectedLeagues(sportsSeasonId, db, { eventName: "Season Complete" });
// Auto-trigger probability recalculation after result
try {
@ -1108,7 +1109,7 @@ export async function recalculateStandings(
export async function recalculateAffectedLeagues(
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>,
options?: { eventName?: string }
options?: { eventName?: string; eventId?: string }
): Promise<void> {
const db = providedDb || database();
@ -1119,6 +1120,42 @@ export async function recalculateAffectedLeagues(
const seasonIds = seasonSports.map((ss) => ss.seasonId);
// Look up sport name for the notification header
const sportsSeason = await db.query.sportsSeasons.findFirst({
where: eq(schema.sportsSeasons.id, sportsSeasonId),
with: { sport: true },
});
const sportName = sportsSeason?.sport?.name;
// Look up completed matches for the event (with participant names) if an eventId was provided
let allCompletedMatches: Array<{
winnerId: string | null;
loserId: string | null;
winnerName: string | null;
loserName: string | null;
}> = [];
if (options?.eventId) {
const matches = await db.query.playoffMatches.findMany({
where: and(
eq(schema.playoffMatches.scoringEventId, options.eventId),
eq(schema.playoffMatches.isComplete, true)
),
with: {
winner: true,
loser: true,
},
});
allCompletedMatches = matches
.filter((m) => m.winnerId && m.loserId)
.map((m) => ({
winnerId: m.winnerId,
loserId: m.loserId,
winnerName: m.winner?.name ?? null,
loserName: m.loser?.name ?? null,
}));
}
// Recalculate each affected season
for (const seasonId of seasonIds) {
// Capture standings before recalculation for delta calculation
@ -1155,6 +1192,30 @@ export async function recalculateAffectedLeagues(
const webhookUrl = season?.league?.discordWebhookUrl;
if (!webhookUrl) continue;
// Filter matches to only those involving participants drafted in this season
let scoredMatches: ScoredMatch[] | undefined;
if (allCompletedMatches.length > 0) {
const draftPicks = await db.query.draftPicks.findMany({
where: eq(schema.draftPicks.seasonId, seasonId),
});
const draftedIds = new Set(draftPicks.map((p) => p.participantId));
const relevant = allCompletedMatches.filter(
(m) =>
(m.winnerId && draftedIds.has(m.winnerId)) ||
(m.loserId && draftedIds.has(m.loserId))
);
if (relevant.length > 0) {
scoredMatches = relevant
.filter((m) => m.winnerName && m.loserName)
.map((m) => ({
winnerName: m.winnerName!,
loserName: m.loserName!,
}));
}
}
const standings = afterStandings.map((s) => ({
teamId: s.teamId,
teamName: s.team.name,
@ -1168,7 +1229,9 @@ export async function recalculateAffectedLeagues(
seasonName: `${season.league.name} ${season.year}`,
standings,
previousStandings: previousPointsById,
sportName,
eventName: options?.eventName,
scoredMatches,
});
} catch (err) {
// Log but don't fail the scoring pipeline if Discord is unreachable

View file

@ -257,6 +257,7 @@ export async function action({ request, params }: Route.ActionArgs) {
isScoring: match.isScoring ?? true,
sportsSeasonId: event.sportsSeasonId,
bracketTemplateId: event.bracketTemplateId,
eventId: event.id,
eventName: event.name ?? undefined,
});
@ -375,7 +376,7 @@ export async function action({ request, params }: Route.ActionArgs) {
// Run side effects once for the whole batch (avoids N redundant recalcs).
if (successCount > 0) {
const db = database();
await recalculateAffectedLeagues(event.sportsSeasonId, db, event.name ? { eventName: event.name } : undefined);
await recalculateAffectedLeagues(event.sportsSeasonId, db, { eventId: event.id, eventName: event.name ?? undefined });
try {
await updateProbabilitiesAfterResult(event.sportsSeasonId, true);
} catch (error) {

View file

@ -4,6 +4,7 @@ import { getAuth } from "@clerk/react-router/server";
import { format } from "date-fns";
import { CalendarIcon } from "lucide-react";
import { findLeagueById, updateLeague, deleteLeague } from "~/models/league";
import { sendStandingsUpdateNotification } from "~/services/discord";
import {
isCommissioner,
findCommissionersByLeagueId,
@ -190,6 +191,7 @@ export async function action(args: Route.ActionArgs) {
if (intent === "update") {
const name = formData.get("name");
const isPublicDraftBoard = formData.get("isPublicDraftBoard") === "on";
const discordWebhookUrl = formData.get("discordWebhookUrl");
if (typeof name !== "string" || !name.trim()) {
return { error: "League name is required" };
@ -198,10 +200,17 @@ export async function action(args: Route.ActionArgs) {
return { error: "League name must be between 3 and 50 characters" };
}
const webhookUrl =
typeof discordWebhookUrl === "string" ? discordWebhookUrl.trim() : "";
if (webhookUrl && !webhookUrl.startsWith("https://discord.com/api/webhooks/")) {
return { error: "Discord webhook URL must start with https://discord.com/api/webhooks/" };
}
try {
await updateLeague(leagueId, {
name: name.trim(),
isPublicDraftBoard,
discordWebhookUrl: webhookUrl || null,
});
} catch (error) {
console.error("Error updating league:", error);
@ -209,6 +218,36 @@ export async function action(args: Route.ActionArgs) {
}
}
if (intent === "test-discord-webhook") {
const webhookUrl = formData.get("webhookUrl");
if (typeof webhookUrl !== "string" || !webhookUrl.startsWith("https://discord.com/api/webhooks/")) {
return { error: "Invalid Discord webhook URL" };
}
try {
const testSeason = await findCurrentSeasonWithSports(leagueId);
const seasonName = testSeason ? `${league.name} ${testSeason.year}` : league.name;
await sendStandingsUpdateNotification({
webhookUrl,
seasonName,
standings: [
{ teamId: "1", teamName: "Team Alpha", totalPoints: 150, rank: 1 },
{ teamId: "2", teamName: "Team Beta", totalPoints: 125, rank: 2 },
{ teamId: "3", teamName: "Team Gamma", totalPoints: 100, rank: 3 },
],
previousStandings: new Map([
["1", 125],
["2", 125],
["3", 100],
]),
eventName: "Test Notification",
});
return { testSuccess: true };
} catch (err) {
console.error("Discord test webhook failed:", err);
return { error: "Failed to send test notification. Check your webhook URL." };
}
}
// Get current season to check status
const season = await findCurrentSeasonWithSports(leagueId);
if (!season) {
@ -787,6 +826,43 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
</CardContent>
</Card>
{/* Notifications */}
<Card>
<CardHeader>
<CardTitle>Notifications</CardTitle>
<CardDescription>
Get Discord alerts when standings change after scoring events
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="discordWebhookUrl">Discord Webhook URL</Label>
<Input
id="discordWebhookUrl"
name="discordWebhookUrl"
type="url"
defaultValue={league.discordWebhookUrl ?? ""}
placeholder="https://discord.com/api/webhooks/..."
/>
<p className="text-sm text-muted-foreground">
Create a webhook in your Discord server under Server Settings Integrations Webhooks.
</p>
</div>
{league.discordWebhookUrl && (
<Form method="post" className="pt-2">
<input type="hidden" name="intent" value="test-discord-webhook" />
<input type="hidden" name="webhookUrl" value={league.discordWebhookUrl} />
<Button type="submit" variant="outline" size="sm">
Send Test Notification
</Button>
</Form>
)}
{actionData && "testSuccess" in actionData && actionData.testSuccess && (
<p className="text-sm text-emerald-600">Test notification sent to Discord!</p>
)}
</CardContent>
</Card>
{/* Draft Rounds */}
<Card>
<CardHeader>

View file

@ -0,0 +1,177 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { sendDiscordWebhook, sendStandingsUpdateNotification } from "../discord";
const WEBHOOK_URL = "https://discord.com/api/webhooks/123/abc";
function mockFetch(status: number, body = "") {
return vi.fn().mockResolvedValue({
ok: status >= 200 && status < 300,
status,
text: async () => body,
});
}
function getDescription(): string {
const body = JSON.parse(
(fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body
);
return body.embeds[0].description as string;
}
describe("sendDiscordWebhook", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch(204));
});
it("POSTs JSON to the webhook URL", async () => {
await sendDiscordWebhook(WEBHOOK_URL, { content: "hello" });
expect(fetch).toHaveBeenCalledWith(WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: "hello" }),
});
});
it("throws on non-2xx response", async () => {
vi.stubGlobal("fetch", mockFetch(400, "Bad Request"));
await expect(sendDiscordWebhook(WEBHOOK_URL, {})).rejects.toThrow(
"Discord webhook failed: 400"
);
});
});
describe("sendStandingsUpdateNotification", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch(204));
});
it("sends an embed with the season name as title", async () => {
await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "My League 2025",
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 150, rank: 1 }],
previousStandings: new Map(),
});
const body = JSON.parse((fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body);
expect(body.embeds[0].title).toBe("📊 Standings Update — My League 2025");
});
it("shows point deltas as integers in description", async () => {
await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "Test League",
standings: [
{ teamId: "a", teamName: "Alpha", totalPoints: 150, rank: 1 },
{ teamId: "b", teamName: "Beta", totalPoints: 100, rank: 2 },
],
previousStandings: new Map([
["a", 125],
["b", 100],
]),
});
const desc = getDescription();
expect(desc).toContain("Alpha");
expect(desc).toContain("+25 pts");
expect(desc).not.toContain("+25.0");
// Beta didn't change — no delta shown
expect(desc).toMatch(/Beta.*100 pts(?!\s*\()/);
});
it("includes sport name and event name in header when both provided", async () => {
await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "My League 2025",
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }],
previousStandings: new Map(),
sportName: "UEFA Champions League",
eventName: "Knockout Stage",
});
const desc = getDescription();
expect(desc).toContain("UEFA Champions League — Knockout Stage");
});
it("shows only event name when no sport name provided", async () => {
await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "My League 2025",
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }],
previousStandings: new Map(),
eventName: "Quarter Finals",
});
const desc = getDescription();
expect(desc).toContain("Quarter Finals");
expect(desc).not.toContain("—\n");
});
it("omits header section when neither sport name nor event name provided", async () => {
await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "My League 2025",
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }],
previousStandings: new Map(),
});
const desc = getDescription();
// Description should start directly with the standings section
expect(desc).toContain("**Current Standings**");
expect(desc).not.toContain("**Scored Matches**");
});
it("lists scored matches above standings", async () => {
await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "My League 2025",
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }],
previousStandings: new Map(),
scoredMatches: [
{ winnerName: "Real Madrid", loserName: "Bayern Munich" },
{ winnerName: "Arsenal", loserName: "PSG" },
],
});
const desc = getDescription();
expect(desc).toContain("**Scored Matches**");
expect(desc).toContain("• **Real Madrid** def. Bayern Munich");
expect(desc).toContain("• **Arsenal** def. PSG");
// Scored matches section should appear before standings
expect(desc.indexOf("Scored Matches")).toBeLessThan(desc.indexOf("Current Standings"));
});
it("uses medal emoji for top 3 ranks in description", async () => {
await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "My League 2025",
standings: [
{ teamId: "a", teamName: "Alpha", totalPoints: 150, rank: 1 },
{ teamId: "b", teamName: "Beta", totalPoints: 125, rank: 2 },
{ teamId: "c", teamName: "Gamma", totalPoints: 100, rank: 3 },
{ teamId: "d", teamName: "Delta", totalPoints: 75, rank: 4 },
],
previousStandings: new Map(),
});
const desc = getDescription();
expect(desc).toContain("🥇 Alpha");
expect(desc).toContain("🥈 Beta");
expect(desc).toContain("🥉 Gamma");
expect(desc).toContain("4. Delta");
});
it("does not show fields on the embed", async () => {
await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "My League 2025",
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }],
previousStandings: new Map(),
});
const body = JSON.parse((fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body);
expect(body.embeds[0].fields).toBeUndefined();
});
});

View file

@ -1,16 +1,41 @@
interface StandingsEntry {
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 | null;
rank: number;
}
interface SendStandingsUpdateOptions {
webhookUrl: string;
seasonName: string;
standings: StandingsEntry[];
previousStandings: Map<string, number> | Record<string, number>;
eventName?: string;
export interface ScoredMatch {
winnerName: string;
loserName: string;
}
export async function sendStandingsUpdateNotification({
@ -18,44 +43,64 @@ export async function sendStandingsUpdateNotification({
seasonName,
standings,
previousStandings,
sportName,
eventName,
}: SendStandingsUpdateOptions): Promise<void> {
const title = eventName
? `📊 ${seasonName} — Standings after ${eventName}`
: `📊 ${seasonName} — Standings Update`;
scoredMatches,
}: {
webhookUrl: string;
seasonName: string;
standings: StandingEntry[];
previousStandings: Map<string, number>;
sportName?: string;
eventName?: string;
scoredMatches?: ScoredMatch[];
}): Promise<void> {
const RANK_MEDALS = ["🥇", "🥈", "🥉"];
const getPrev = (teamId: string) =>
previousStandings instanceof Map
? previousStandings.get(teamId)
: previousStandings[teamId];
const sections: string[] = [];
const lines = standings
.sort((a, b) => (a.rank ?? 999) - (b.rank ?? 999))
.map((s) => {
const prev = getPrev(s.teamId);
const diff =
prev !== undefined ? s.totalPoints - prev : null;
const diffStr =
diff !== null && diff !== 0
? diff > 0
? ` (+${diff.toFixed(1)})`
: ` (${diff.toFixed(1)})`
: "";
const rankStr = s.rank != null ? `${s.rank}.` : "-";
return `${rankStr} **${s.teamName}** — ${s.totalPoints.toFixed(1)} pts${diffStr}`;
});
// Header: "Sport Name — Event Name"
if (sportName || eventName) {
const parts = [sportName, eventName].filter(Boolean);
sections.push(`**${parts.join(" — ")}**`);
}
const content = [`**${title}**`, "", ...lines].join("\n");
const response = await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (!response.ok) {
throw new Error(
`Discord webhook returned ${response.status}: ${await response.text()}`
);
// 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);
let delta = "";
if (prev !== undefined && prev !== s.totalPoints) {
const diff = Math.round(s.totalPoints - prev);
const sign = diff > 0 ? "+" : "";
delta = ` **(${sign}${diff} pts)**`;
}
const medal = RANK_MEDALS[s.rank - 1] ?? `${s.rank}.`;
sections.push(`${medal} ${s.teamName}${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" },
},
],
});
}

View file

@ -99,7 +99,7 @@ export const leagues = pgTable("leagues", {
createdBy: varchar("created_by", { length: 255 }).notNull(), // Clerk user ID
currentSeasonId: uuid("current_season_id"), // References the active season
isPublicDraftBoard: boolean("is_public_draft_board").notNull().default(false),
discordWebhookUrl: varchar("discord_webhook_url", { length: 500 }),
discordWebhookUrl: text("discord_webhook_url"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});

View file

@ -0,0 +1 @@
ALTER TABLE "leagues" ADD COLUMN "discord_webhook_url" text;

View file

@ -1,5 +1,5 @@
{
"id": "073631b4-6a21-4bfe-b287-ec752c45fe05",
"id": "b36f2af2-46ba-48e1-9c59-eb38bcbfdedc",
"prevId": "16019fc9-f516-4d33-86ba-c2971ca492c0",
"version": "7",
"dialect": "postgresql",
@ -681,7 +681,7 @@
},
"discord_webhook_url": {
"name": "discord_webhook_url",
"type": "varchar(500)",
"type": "text",
"primaryKey": false,
"notNull": false
},

View file

@ -341,8 +341,8 @@
{
"idx": 48,
"version": "7",
"when": 1773769397138,
"tag": "0048_mean_enchantress",
"when": 1773765768905,
"tag": "0048_eager_songbird",
"breakpoints": true
}
]