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:
parent
dbffddc9ff
commit
79f7a41837
10 changed files with 421 additions and 57 deletions
|
|
@ -46,6 +46,7 @@ function makeDb(existingResult?: { id: string; isPartialScore: boolean }) {
|
||||||
seasonSports: {
|
seasonSports: {
|
||||||
findMany: vi.fn().mockResolvedValue([]), // empty → standings loop exits immediately
|
findMany: vi.fn().mockResolvedValue([]), // empty → standings loop exits immediately
|
||||||
},
|
},
|
||||||
|
sportsSeasons: { findFirst: vi.fn().mockResolvedValue(null) },
|
||||||
scoringEvents: { findFirst: vi.fn().mockResolvedValue(null) },
|
scoringEvents: { findFirst: vi.fn().mockResolvedValue(null) },
|
||||||
},
|
},
|
||||||
} as any,
|
} as any,
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { eq, and, inArray } from "drizzle-orm";
|
||||||
import { getScoringRules, calculateFantasyPoints, calculateAveragedPoints, calculateBracketPoints } from "./scoring-rules";
|
import { getScoringRules, calculateFantasyPoints, calculateAveragedPoints, calculateBracketPoints } from "./scoring-rules";
|
||||||
import { getSeasonResults } from "./participant-season-result";
|
import { getSeasonResults } from "./participant-season-result";
|
||||||
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
||||||
import { sendStandingsUpdateNotification } from "~/services/discord";
|
import { sendStandingsUpdateNotification, type ScoredMatch } from "~/services/discord";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Core scoring calculation engine
|
* Core scoring calculation engine
|
||||||
|
|
@ -274,7 +274,7 @@ export async function processPlayoffEvent(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recalculate standings for all affected leagues
|
// 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
|
// Auto-trigger probability recalculation after result
|
||||||
try {
|
try {
|
||||||
|
|
@ -310,13 +310,14 @@ export async function processMatchResult(
|
||||||
isScoring: boolean;
|
isScoring: boolean;
|
||||||
sportsSeasonId: string;
|
sportsSeasonId: string;
|
||||||
bracketTemplateId?: string | null;
|
bracketTemplateId?: string | null;
|
||||||
|
eventId?: string;
|
||||||
eventName?: string;
|
eventName?: string;
|
||||||
skipSideEffects?: boolean;
|
skipSideEffects?: boolean;
|
||||||
},
|
},
|
||||||
providedDb?: ReturnType<typeof database>
|
providedDb?: ReturnType<typeof database>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const db = providedDb || database();
|
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) {
|
if (!isScoring) {
|
||||||
// Non-scoring (pre-bracket) round: loser permanently eliminated (0 pts),
|
// Non-scoring (pre-bracket) round: loser permanently eliminated (0 pts),
|
||||||
|
|
@ -346,7 +347,7 @@ export async function processMatchResult(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!skipSideEffects) {
|
if (!skipSideEffects) {
|
||||||
await recalculateAffectedLeagues(sportsSeasonId, db, eventName ? { eventName } : undefined);
|
await recalculateAffectedLeagues(sportsSeasonId, db, (eventName || eventId) ? { eventName, eventId } : undefined);
|
||||||
try {
|
try {
|
||||||
await updateProbabilitiesAfterResult(sportsSeasonId, true);
|
await updateProbabilitiesAfterResult(sportsSeasonId, true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -629,7 +630,7 @@ export async function finalizeQualifyingPoints(
|
||||||
.where(eq(schema.sportsSeasons.id, sportsSeasonId));
|
.where(eq(schema.sportsSeasons.id, sportsSeasonId));
|
||||||
|
|
||||||
// Trigger recalculation for all affected leagues
|
// Trigger recalculation for all affected leagues
|
||||||
await recalculateAffectedLeagues(sportsSeasonId, db);
|
await recalculateAffectedLeagues(sportsSeasonId, db, { eventName: "Final Standings" });
|
||||||
|
|
||||||
// Auto-trigger probability recalculation after result
|
// Auto-trigger probability recalculation after result
|
||||||
try {
|
try {
|
||||||
|
|
@ -739,7 +740,7 @@ export async function processSeasonStandings(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trigger recalculation for all affected leagues
|
// Trigger recalculation for all affected leagues
|
||||||
await recalculateAffectedLeagues(sportsSeasonId, db);
|
await recalculateAffectedLeagues(sportsSeasonId, db, { eventName: "Season Complete" });
|
||||||
|
|
||||||
// Auto-trigger probability recalculation after result
|
// Auto-trigger probability recalculation after result
|
||||||
try {
|
try {
|
||||||
|
|
@ -1108,7 +1109,7 @@ export async function recalculateStandings(
|
||||||
export async function recalculateAffectedLeagues(
|
export async function recalculateAffectedLeagues(
|
||||||
sportsSeasonId: string,
|
sportsSeasonId: string,
|
||||||
providedDb?: ReturnType<typeof database>,
|
providedDb?: ReturnType<typeof database>,
|
||||||
options?: { eventName?: string }
|
options?: { eventName?: string; eventId?: string }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const db = providedDb || database();
|
const db = providedDb || database();
|
||||||
|
|
||||||
|
|
@ -1119,6 +1120,42 @@ export async function recalculateAffectedLeagues(
|
||||||
|
|
||||||
const seasonIds = seasonSports.map((ss) => ss.seasonId);
|
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
|
// Recalculate each affected season
|
||||||
for (const seasonId of seasonIds) {
|
for (const seasonId of seasonIds) {
|
||||||
// Capture standings before recalculation for delta calculation
|
// Capture standings before recalculation for delta calculation
|
||||||
|
|
@ -1155,6 +1192,30 @@ export async function recalculateAffectedLeagues(
|
||||||
const webhookUrl = season?.league?.discordWebhookUrl;
|
const webhookUrl = season?.league?.discordWebhookUrl;
|
||||||
if (!webhookUrl) continue;
|
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) => ({
|
const standings = afterStandings.map((s) => ({
|
||||||
teamId: s.teamId,
|
teamId: s.teamId,
|
||||||
teamName: s.team.name,
|
teamName: s.team.name,
|
||||||
|
|
@ -1168,7 +1229,9 @@ export async function recalculateAffectedLeagues(
|
||||||
seasonName: `${season.league.name} ${season.year}`,
|
seasonName: `${season.league.name} ${season.year}`,
|
||||||
standings,
|
standings,
|
||||||
previousStandings: previousPointsById,
|
previousStandings: previousPointsById,
|
||||||
|
sportName,
|
||||||
eventName: options?.eventName,
|
eventName: options?.eventName,
|
||||||
|
scoredMatches,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Log but don't fail the scoring pipeline if Discord is unreachable
|
// Log but don't fail the scoring pipeline if Discord is unreachable
|
||||||
|
|
|
||||||
|
|
@ -257,6 +257,7 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
isScoring: match.isScoring ?? true,
|
isScoring: match.isScoring ?? true,
|
||||||
sportsSeasonId: event.sportsSeasonId,
|
sportsSeasonId: event.sportsSeasonId,
|
||||||
bracketTemplateId: event.bracketTemplateId,
|
bracketTemplateId: event.bracketTemplateId,
|
||||||
|
eventId: event.id,
|
||||||
eventName: event.name ?? undefined,
|
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).
|
// Run side effects once for the whole batch (avoids N redundant recalcs).
|
||||||
if (successCount > 0) {
|
if (successCount > 0) {
|
||||||
const db = database();
|
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 {
|
try {
|
||||||
await updateProbabilitiesAfterResult(event.sportsSeasonId, true);
|
await updateProbabilitiesAfterResult(event.sportsSeasonId, true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { getAuth } from "@clerk/react-router/server";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { CalendarIcon } from "lucide-react";
|
import { CalendarIcon } from "lucide-react";
|
||||||
import { findLeagueById, updateLeague, deleteLeague } from "~/models/league";
|
import { findLeagueById, updateLeague, deleteLeague } from "~/models/league";
|
||||||
|
import { sendStandingsUpdateNotification } from "~/services/discord";
|
||||||
import {
|
import {
|
||||||
isCommissioner,
|
isCommissioner,
|
||||||
findCommissionersByLeagueId,
|
findCommissionersByLeagueId,
|
||||||
|
|
@ -190,6 +191,7 @@ export async function action(args: Route.ActionArgs) {
|
||||||
if (intent === "update") {
|
if (intent === "update") {
|
||||||
const name = formData.get("name");
|
const name = formData.get("name");
|
||||||
const isPublicDraftBoard = formData.get("isPublicDraftBoard") === "on";
|
const isPublicDraftBoard = formData.get("isPublicDraftBoard") === "on";
|
||||||
|
const discordWebhookUrl = formData.get("discordWebhookUrl");
|
||||||
|
|
||||||
if (typeof name !== "string" || !name.trim()) {
|
if (typeof name !== "string" || !name.trim()) {
|
||||||
return { error: "League name is required" };
|
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" };
|
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 {
|
try {
|
||||||
await updateLeague(leagueId, {
|
await updateLeague(leagueId, {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
isPublicDraftBoard,
|
isPublicDraftBoard,
|
||||||
|
discordWebhookUrl: webhookUrl || null,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating league:", 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
|
// Get current season to check status
|
||||||
const season = await findCurrentSeasonWithSports(leagueId);
|
const season = await findCurrentSeasonWithSports(leagueId);
|
||||||
if (!season) {
|
if (!season) {
|
||||||
|
|
@ -787,6 +826,43 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 */}
|
{/* Draft Rounds */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
|
||||||
177
app/services/__tests__/discord.test.ts
Normal file
177
app/services/__tests__/discord.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;
|
teamId: string;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
totalPoints: number;
|
totalPoints: number;
|
||||||
rank: number | null;
|
rank: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SendStandingsUpdateOptions {
|
export interface ScoredMatch {
|
||||||
webhookUrl: string;
|
winnerName: string;
|
||||||
seasonName: string;
|
loserName: string;
|
||||||
standings: StandingsEntry[];
|
|
||||||
previousStandings: Map<string, number> | Record<string, number>;
|
|
||||||
eventName?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendStandingsUpdateNotification({
|
export async function sendStandingsUpdateNotification({
|
||||||
|
|
@ -18,44 +43,64 @@ export async function sendStandingsUpdateNotification({
|
||||||
seasonName,
|
seasonName,
|
||||||
standings,
|
standings,
|
||||||
previousStandings,
|
previousStandings,
|
||||||
|
sportName,
|
||||||
eventName,
|
eventName,
|
||||||
}: SendStandingsUpdateOptions): Promise<void> {
|
scoredMatches,
|
||||||
const title = eventName
|
}: {
|
||||||
? `📊 ${seasonName} — Standings after ${eventName}`
|
webhookUrl: string;
|
||||||
: `📊 ${seasonName} — Standings Update`;
|
seasonName: string;
|
||||||
|
standings: StandingEntry[];
|
||||||
|
previousStandings: Map<string, number>;
|
||||||
|
sportName?: string;
|
||||||
|
eventName?: string;
|
||||||
|
scoredMatches?: ScoredMatch[];
|
||||||
|
}): Promise<void> {
|
||||||
|
const RANK_MEDALS = ["🥇", "🥈", "🥉"];
|
||||||
|
|
||||||
const getPrev = (teamId: string) =>
|
const sections: string[] = [];
|
||||||
previousStandings instanceof Map
|
|
||||||
? previousStandings.get(teamId)
|
|
||||||
: previousStandings[teamId];
|
|
||||||
|
|
||||||
const lines = standings
|
// Header: "Sport Name — Event Name"
|
||||||
.sort((a, b) => (a.rank ?? 999) - (b.rank ?? 999))
|
if (sportName || eventName) {
|
||||||
.map((s) => {
|
const parts = [sportName, eventName].filter(Boolean);
|
||||||
const prev = getPrev(s.teamId);
|
sections.push(`**${parts.join(" — ")}**`);
|
||||||
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}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const content = [`**${title}**`, "", ...lines].join("\n");
|
// Scored matches section
|
||||||
|
if (scoredMatches && scoredMatches.length > 0) {
|
||||||
const response = await fetch(webhookUrl, {
|
sections.push("**Scored Matches**");
|
||||||
method: "POST",
|
for (const match of scoredMatches) {
|
||||||
headers: { "Content-Type": "application/json" },
|
sections.push(`• **${match.winnerName}** def. ${match.loserName}`);
|
||||||
body: JSON.stringify({ content }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(
|
|
||||||
`Discord webhook returned ${response.status}: ${await response.text()}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,7 @@ export const leagues = pgTable("leagues", {
|
||||||
createdBy: varchar("created_by", { length: 255 }).notNull(), // Clerk user ID
|
createdBy: varchar("created_by", { length: 255 }).notNull(), // Clerk user ID
|
||||||
currentSeasonId: uuid("current_season_id"), // References the active season
|
currentSeasonId: uuid("current_season_id"), // References the active season
|
||||||
isPublicDraftBoard: boolean("is_public_draft_board").notNull().default(false),
|
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(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
1
drizzle/0048_eager_songbird.sql
Normal file
1
drizzle/0048_eager_songbird.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE "leagues" ADD COLUMN "discord_webhook_url" text;
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"id": "073631b4-6a21-4bfe-b287-ec752c45fe05",
|
"id": "b36f2af2-46ba-48e1-9c59-eb38bcbfdedc",
|
||||||
"prevId": "16019fc9-f516-4d33-86ba-c2971ca492c0",
|
"prevId": "16019fc9-f516-4d33-86ba-c2971ca492c0",
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "postgresql",
|
"dialect": "postgresql",
|
||||||
|
|
@ -681,7 +681,7 @@
|
||||||
},
|
},
|
||||||
"discord_webhook_url": {
|
"discord_webhook_url": {
|
||||||
"name": "discord_webhook_url",
|
"name": "discord_webhook_url",
|
||||||
"type": "varchar(500)",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false
|
"notNull": false
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -341,8 +341,8 @@
|
||||||
{
|
{
|
||||||
"idx": 48,
|
"idx": 48,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1773769397138,
|
"when": 1773765768905,
|
||||||
"tag": "0048_mean_enchantress",
|
"tag": "0048_eager_songbird",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue