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>
This commit is contained in:
parent
dbffddc9ff
commit
1f8a69a2b7
8 changed files with 404 additions and 52 deletions
|
|
@ -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 {
|
||||||
|
|
@ -1108,7 +1108,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 +1119,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 +1191,31 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -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,34 @@ 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 {
|
||||||
|
await sendStandingsUpdateNotification({
|
||||||
|
webhookUrl,
|
||||||
|
seasonName: league.name,
|
||||||
|
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 +824,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,56 @@ 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 =
|
// Scored matches section
|
||||||
diff !== null && diff !== 0
|
if (scoredMatches && scoredMatches.length > 0) {
|
||||||
? diff > 0
|
sections.push("**Scored Matches**");
|
||||||
? ` (+${diff.toFixed(1)})`
|
for (const match of scoredMatches) {
|
||||||
: ` (${diff.toFixed(1)})`
|
sections.push(`⚽ **${match.winnerName}** def. ${match.loserName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standings section
|
||||||
|
sections.push("**Current Standings**");
|
||||||
|
for (const s of standings) {
|
||||||
|
const prev = previousStandings.get(s.teamId);
|
||||||
|
const delta =
|
||||||
|
prev !== undefined && prev !== s.totalPoints
|
||||||
|
? ` **(+${Math.round(s.totalPoints - prev)} pts)**`
|
||||||
: "";
|
: "";
|
||||||
const rankStr = s.rank != null ? `${s.rank}.` : "-";
|
const medal = RANK_MEDALS[s.rank - 1] ?? `${s.rank}.`;
|
||||||
return `${rankStr} **${s.teamName}** — ${s.totalPoints.toFixed(1)} pts${diffStr}`;
|
sections.push(`${medal} ${s.teamName} — ${Math.round(s.totalPoints)} pts${delta}`);
|
||||||
});
|
|
||||||
|
|
||||||
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()}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await sendDiscordWebhook(webhookUrl, {
|
||||||
|
embeds: [
|
||||||
|
{
|
||||||
|
title: `📊 Standings Update — ${seasonName}`,
|
||||||
|
description: sections.join("\n"),
|
||||||
|
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