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 { 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 {
|
||||
|
|
@ -1108,7 +1108,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 +1119,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 +1191,31 @@ 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
|
||||
|
|
|
|||
|
|
@ -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,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
|
||||
const season = await findCurrentSeasonWithSports(leagueId);
|
||||
if (!season) {
|
||||
|
|
@ -787,6 +824,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>
|
||||
|
|
|
|||
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;
|
||||
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,56 @@ 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}`;
|
||||
});
|
||||
|
||||
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()}`
|
||||
);
|
||||
// Header: "Sport Name — Event Name"
|
||||
if (sportName || eventName) {
|
||||
const parts = [sportName, eventName].filter(Boolean);
|
||||
sections.push(`**${parts.join(" — ")}**`);
|
||||
}
|
||||
|
||||
// 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);
|
||||
const delta =
|
||||
prev !== undefined && prev !== s.totalPoints
|
||||
? ` **(+${Math.round(s.totalPoints - prev)} pts)**`
|
||||
: "";
|
||||
const medal = RANK_MEDALS[s.rank - 1] ?? `${s.rank}.`;
|
||||
sections.push(`${medal} ${s.teamName} — ${Math.round(s.totalPoints)} pts${delta}`);
|
||||
}
|
||||
|
||||
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
|
||||
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(),
|
||||
});
|
||||
|
|
|
|||
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",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
|
|
@ -681,7 +681,7 @@
|
|||
},
|
||||
"discord_webhook_url": {
|
||||
"name": "discord_webhook_url",
|
||||
"type": "varchar(500)",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
|
|
|
|||
|
|
@ -341,8 +341,8 @@
|
|||
{
|
||||
"idx": 48,
|
||||
"version": "7",
|
||||
"when": 1773769397138,
|
||||
"tag": "0048_mean_enchantress",
|
||||
"when": 1773765768905,
|
||||
"tag": "0048_eager_songbird",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue