diff --git a/prisma/migrations/20260516141645_add_discord_webhook_config/migration.sql b/prisma/migrations/20260516141645_add_discord_webhook_config/migration.sql new file mode 100644 index 0000000..50cd46b --- /dev/null +++ b/prisma/migrations/20260516141645_add_discord_webhook_config/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "discord_webhook_configs" ( + "id" TEXT NOT NULL, + "league_id" TEXT NOT NULL, + "webhook_url" TEXT NOT NULL, + "on_event_created" BOOLEAN NOT NULL DEFAULT true, + "on_day_of_event" BOOLEAN NOT NULL DEFAULT true, + "on_results_uploaded" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "discord_webhook_configs_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "discord_webhook_configs_league_id_key" ON "discord_webhook_configs"("league_id"); + +-- AddForeignKey +ALTER TABLE "discord_webhook_configs" ADD CONSTRAINT "discord_webhook_configs_league_id_fkey" FOREIGN KEY ("league_id") REFERENCES "leagues"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 892d69c..f413676 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -68,10 +68,26 @@ model League { moneyEvents VirtualMoneyEvent[] recruitingSeries LeagueRecruitingSeries[] joinRequests LeagueJoinRequest[] + discordWebhook DiscordWebhookConfig? @@map("leagues") } +model DiscordWebhookConfig { + id String @id @default(cuid()) + leagueId String @unique @map("league_id") + webhookUrl String @map("webhook_url") + onEventCreated Boolean @default(true) @map("on_event_created") + onDayOfEvent Boolean @default(true) @map("on_day_of_event") + onResultsUploaded Boolean @default(true) @map("on_results_uploaded") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + league League @relation(fields: [leagueId], references: [id], onDelete: Cascade) + + @@map("discord_webhook_configs") +} + model LeagueMembership { id String @id @default(cuid()) userId String @map("user_id") diff --git a/public/branding/iracehub-discord-banner.png b/public/branding/iracehub-discord-banner.png new file mode 100644 index 0000000..ed678ae Binary files /dev/null and b/public/branding/iracehub-discord-banner.png differ diff --git a/public/branding/iracehub-discord-banner.svg b/public/branding/iracehub-discord-banner.svg new file mode 100644 index 0000000..36ee27f --- /dev/null +++ b/public/branding/iracehub-discord-banner.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + iRaceHub + + + + Open-Source League Management for iRacing + + + + + iracehub.com + + + + diff --git a/src/app/api/cron/day-of-event/route.ts b/src/app/api/cron/day-of-event/route.ts new file mode 100644 index 0000000..044bc19 --- /dev/null +++ b/src/app/api/cron/day-of-event/route.ts @@ -0,0 +1,128 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { notifyDayOfEvent } from "@/lib/discord/webhook"; + +/** + * GET /api/cron/day-of-event + * + * Fires Discord "day of event" notifications for all leagues that have a race + * scheduled today and have a Discord webhook configured with onDayOfEvent enabled. + * + * Intended to be called once per day (e.g. via Vercel Cron or an external + * scheduler). Protect with a secret token in production. + * + * Example Vercel cron config (vercel.json): + * { "crons": [{ "path": "/api/cron/day-of-event", "schedule": "0 12 * * *" }] } + */ +export async function GET(req: NextRequest) { + // Optional: verify a shared secret to prevent unauthorized triggers + const cronSecret = process.env.CRON_SECRET; + if (cronSecret) { + const authHeader = req.headers.get("authorization"); + const providedToken = authHeader?.replace("Bearer ", ""); + if (providedToken !== cronSecret) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + } + + const now = new Date(); + + // Build a UTC day window [start of today, start of tomorrow) + const todayStart = new Date( + Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth(), + now.getUTCDate(), + 0, + 0, + 0, + 0, + ), + ); + const todayEnd = new Date(todayStart.getTime() + 24 * 60 * 60 * 1000); + + // Find all non-off-week schedules happening today that have a league with a + // Discord webhook configured with onDayOfEvent enabled + const schedulesToday = await prisma.schedule.findMany({ + where: { + isOffWeek: false, + eventDate: { gte: todayStart, lt: todayEnd }, + season: { + series: { + league: { + discordWebhook: { + onDayOfEvent: true, + }, + }, + }, + }, + }, + select: { + id: true, + raceName: true, + eventDate: true, + trackName: true, + raceLength: true, + _count: { select: { registrations: true } }, + season: { + select: { + series: { + select: { + name: true, + league: { + select: { + leagueName: true, + discordWebhook: { + select: { + webhookUrl: true, + onDayOfEvent: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }); + + let notified = 0; + const errors: string[] = []; + + await Promise.allSettled( + schedulesToday.map(async (schedule) => { + const league = schedule.season.series.league; + const webhook = league.discordWebhook; + + if (!webhook?.onDayOfEvent || !webhook.webhookUrl) return; + + try { + await notifyDayOfEvent(webhook.webhookUrl, { + leagueName: league.leagueName, + seriesName: schedule.season.series.name, + raceName: schedule.raceName, + eventDate: schedule.eventDate, + trackName: schedule.trackName, + raceLength: schedule.raceLength, + registrationCount: schedule._count.registrations, + }); + notified++; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + errors.push(`Schedule ${schedule.id}: ${msg}`); + console.error( + `[Cron/DayOfEvent] Failed for schedule ${schedule.id}:`, + err, + ); + } + }), + ); + + return NextResponse.json({ + date: todayStart.toISOString().split("T")[0], + schedulesFound: schedulesToday.length, + notified, + errors: errors.length > 0 ? errors : undefined, + }); +} diff --git a/src/app/api/leagues/[leagueId]/discord-webhook/route.ts b/src/app/api/leagues/[leagueId]/discord-webhook/route.ts new file mode 100644 index 0000000..c6d9369 --- /dev/null +++ b/src/app/api/leagues/[leagueId]/discord-webhook/route.ts @@ -0,0 +1,178 @@ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { getIracingCustIdFromJwt } from "@/lib/auth/iracing"; +import { notifyEventCreated } from "@/lib/discord/webhook"; + +async function assertLeagueAdmin(leagueId: string, req: NextRequest) { + const accessToken = req.cookies.get("irh_access_token")?.value; + if (!accessToken) return { ok: false as const, status: 401 }; + + const iracingCustId = getIracingCustIdFromJwt(accessToken); + const user = await prisma.user.findUnique({ + where: { iracingCustId }, + select: { id: true }, + }); + if (!user) return { ok: false as const, status: 404 }; + + const membership = await prisma.leagueMembership.findUnique({ + where: { userId_leagueId: { userId: user.id, leagueId } }, + select: { owner: true, admin: true }, + }); + if (!membership || (!membership.owner && !membership.admin)) { + return { ok: false as const, status: 403 }; + } + + return { ok: true as const }; +} + +export async function GET( + req: NextRequest, + context: { params: Promise<{ leagueId: string }> }, +) { + const { leagueId } = await context.params; + + const auth = await assertLeagueAdmin(leagueId, req); + if (!auth.ok) { + return Response.json({ error: "unauthorized" }, { status: auth.status }); + } + + const config = await prisma.discordWebhookConfig.findUnique({ + where: { leagueId }, + }); + + if (!config) { + return Response.json({ + webhookUrl: null, + onEventCreated: true, + onDayOfEvent: true, + onResultsUploaded: true, + }); + } + + return Response.json({ + webhookUrl: config.webhookUrl, + onEventCreated: config.onEventCreated, + onDayOfEvent: config.onDayOfEvent, + onResultsUploaded: config.onResultsUploaded, + }); +} + +interface WebhookConfigBody { + webhookUrl?: string | null; + onEventCreated?: boolean; + onDayOfEvent?: boolean; + onResultsUploaded?: boolean; + test?: boolean; +} + +export async function PATCH( + req: NextRequest, + context: { params: Promise<{ leagueId: string }> }, +) { + const { leagueId } = await context.params; + + const auth = await assertLeagueAdmin(leagueId, req); + if (!auth.ok) { + return Response.json({ error: "unauthorized" }, { status: auth.status }); + } + + const body = (await req.json()) as WebhookConfigBody; + + // Validate webhook URL if provided + if (body.webhookUrl) { + try { + const url = new URL(body.webhookUrl); + if ( + !url.hostname.endsWith("discord.com") && + !url.hostname.endsWith("discordapp.com") + ) { + return Response.json( + { + error: "invalid_webhook_url", + message: "Must be a Discord webhook URL.", + }, + { status: 400 }, + ); + } + } catch { + return Response.json( + { error: "invalid_webhook_url", message: "Invalid URL format." }, + { status: 400 }, + ); + } + } + + // If webhookUrl is explicitly null or empty string, delete the config + if (body.webhookUrl === null || body.webhookUrl === "") { + await prisma.discordWebhookConfig.deleteMany({ where: { leagueId } }); + return Response.json({ webhookUrl: null }); + } + + if (!body.webhookUrl) { + // Partial update β€” only update toggle fields if config exists + const existing = await prisma.discordWebhookConfig.findUnique({ + where: { leagueId }, + }); + if (!existing) { + return Response.json({ error: "no_webhook_configured" }, { status: 404 }); + } + + const updated = await prisma.discordWebhookConfig.update({ + where: { leagueId }, + data: { + onEventCreated: body.onEventCreated ?? existing.onEventCreated, + onDayOfEvent: body.onDayOfEvent ?? existing.onDayOfEvent, + onResultsUploaded: body.onResultsUploaded ?? existing.onResultsUploaded, + }, + }); + + return Response.json({ + webhookUrl: updated.webhookUrl, + onEventCreated: updated.onEventCreated, + onDayOfEvent: updated.onDayOfEvent, + onResultsUploaded: updated.onResultsUploaded, + }); + } + + // Upsert the full config + const config = await prisma.discordWebhookConfig.upsert({ + where: { leagueId }, + create: { + leagueId, + webhookUrl: body.webhookUrl, + onEventCreated: body.onEventCreated ?? true, + onDayOfEvent: body.onDayOfEvent ?? true, + onResultsUploaded: body.onResultsUploaded ?? true, + }, + update: { + webhookUrl: body.webhookUrl, + onEventCreated: body.onEventCreated ?? true, + onDayOfEvent: body.onDayOfEvent ?? true, + onResultsUploaded: body.onResultsUploaded ?? true, + }, + }); + + // Optionally send a test notification + if (body.test) { + const league = await prisma.league.findUnique({ + where: { id: leagueId }, + select: { leagueName: true }, + }); + + void notifyEventCreated(config.webhookUrl, { + leagueName: league?.leagueName ?? "Your League", + seriesName: "Example Series", + raceName: "Test Notification", + eventDate: new Date(), + trackName: "Daytona International Speedway", + raceLength: "50 laps", + }); + } + + return Response.json({ + webhookUrl: config.webhookUrl, + onEventCreated: config.onEventCreated, + onDayOfEvent: config.onDayOfEvent, + onResultsUploaded: config.onResultsUploaded, + }); +} diff --git a/src/app/api/leagues/[leagueId]/series/[seriesId]/seasons/[seasonId]/schedules/route.ts b/src/app/api/leagues/[leagueId]/series/[seriesId]/seasons/[seasonId]/schedules/route.ts index 4d63a1e..4d0f59e 100644 --- a/src/app/api/leagues/[leagueId]/series/[seriesId]/seasons/[seasonId]/schedules/route.ts +++ b/src/app/api/leagues/[leagueId]/series/[seriesId]/seasons/[seasonId]/schedules/route.ts @@ -2,6 +2,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/prisma"; import { getIracingCustIdFromJwt } from "@/lib/auth/iracing"; import { Prisma } from "@prisma/client"; +import { notifyEventCreated } from "@/lib/discord/webhook"; interface ScheduleRequest { eventDate: string; @@ -277,6 +278,46 @@ export async function POST( }, }); + // Fire Discord webhook (best-effort, non-blocking) + void (async () => { + try { + const webhookConfig = await prisma.discordWebhookConfig.findUnique({ + where: { leagueId }, + select: { + webhookUrl: true, + onEventCreated: true, + }, + }); + + if (webhookConfig?.onEventCreated && !data.isOffWeek) { + const [league, series] = await Promise.all([ + prisma.league.findUnique({ + where: { id: leagueId }, + select: { leagueName: true }, + }), + prisma.series.findUnique({ + where: { id: seriesId }, + select: { name: true }, + }), + ]); + + await notifyEventCreated(webhookConfig.webhookUrl, { + leagueName: league?.leagueName ?? leagueId, + seriesName: series?.name ?? "Unknown Series", + raceName: schedule.raceName, + eventDate: schedule.eventDate, + trackName: schedule.trackName, + raceLength: schedule.raceLength, + }); + } + } catch (webhookErr) { + console.error( + "[Discord Webhook] Failed to notify event created:", + webhookErr, + ); + } + })(); + return Response.json(schedule, { status: 201 }); } catch (error) { console.error("Error creating schedule:", error); diff --git a/src/app/api/leagues/[leagueId]/series/[seriesId]/seasons/[seasonId]/sessions/[raceSessionId]/results/import/route.ts b/src/app/api/leagues/[leagueId]/series/[seriesId]/seasons/[seasonId]/sessions/[raceSessionId]/results/import/route.ts index 9466ec4..a93e5ab 100644 --- a/src/app/api/leagues/[leagueId]/series/[seriesId]/seasons/[seasonId]/sessions/[raceSessionId]/results/import/route.ts +++ b/src/app/api/leagues/[leagueId]/series/[seriesId]/seasons/[seasonId]/sessions/[raceSessionId]/results/import/route.ts @@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma"; import { getIracingCustIdFromJwt } from "@/lib/auth/iracing"; import { fetchIracingLinkedJson } from "@/lib/iracing/api"; import { recalculateLeagueVirtualMoney } from "@/lib/virtualMoneyDistribution"; +import { notifyResultsUploaded } from "@/lib/discord/webhook"; function toInputJsonValue(value: Prisma.JsonValue): Prisma.InputJsonValue { if (value === null) return null as unknown as Prisma.InputJsonValue; @@ -430,5 +431,49 @@ export async function POST( await recalculateLeagueVirtualMoney(prisma, leagueId); + // Fire Discord webhook (best-effort, non-blocking) + void (async () => { + try { + const webhookConfig = await prisma.discordWebhookConfig.findUnique({ + where: { leagueId }, + select: { webhookUrl: true, onResultsUploaded: true }, + }); + + if (webhookConfig?.onResultsUploaded) { + const sessionWithDetails = await prisma.raceSession.findUnique({ + where: { id: raceSessionId }, + select: { + trackName: true, + winnerName: true, + leagueId: true, + seriesId: true, + schedule: { + select: { raceName: true, eventDate: true }, + }, + league: { select: { leagueName: true } }, + series: { select: { name: true } }, + }, + }); + + if (sessionWithDetails) { + await notifyResultsUploaded(webhookConfig.webhookUrl, { + leagueName: sessionWithDetails.league.leagueName, + seriesName: sessionWithDetails.series.name, + raceName: sessionWithDetails.schedule?.raceName ?? "Race", + eventDate: sessionWithDetails.schedule?.eventDate ?? new Date(), + trackName: sessionWithDetails.trackName, + winnerName: sessionWithDetails.winnerName, + resultCount: upserted.length, + }); + } + } + } catch (webhookErr) { + console.error( + "[Discord Webhook] Failed to notify results uploaded:", + webhookErr, + ); + } + })(); + return NextResponse.json({ imported: upserted.length, results: upserted }); } diff --git a/src/app/app/[leagueId]/admin/settings/page.tsx b/src/app/app/[leagueId]/admin/settings/page.tsx index e70860f..bc988d6 100644 --- a/src/app/app/[leagueId]/admin/settings/page.tsx +++ b/src/app/app/[leagueId]/admin/settings/page.tsx @@ -39,6 +39,13 @@ interface RecruitingSettingsPayload { openSeries: Array<{ id: string; name: string }>; } +interface DiscordWebhookConfig { + webhookUrl: string | null; + onEventCreated: boolean; + onDayOfEvent: boolean; + onResultsUploaded: boolean; +} + export default function AdminSettingsPage() { const { session, loading: authLoading, logout } = useAuth(); const router = useRouter(); @@ -71,6 +78,15 @@ export default function AdminSettingsPage() { const [pendingIracingLeagueId, setPendingIracingLeagueId] = useState(""); const [linkingIracingLeague, setLinkingIracingLeague] = useState(false); + // Discord Webhook State + const [discordWebhookUrl, setDiscordWebhookUrl] = useState(""); + const [discordOnEventCreated, setDiscordOnEventCreated] = useState(true); + const [discordOnDayOfEvent, setDiscordOnDayOfEvent] = useState(true); + const [discordOnResultsUploaded, setDiscordOnResultsUploaded] = + useState(true); + const [savingDiscord, setSavingDiscord] = useState(false); + const [discordNotice, setDiscordNotice] = useState(null); + useEffect(() => { if (!authLoading && !session?.authenticated) { router.replace("/"); @@ -107,8 +123,8 @@ export default function AdminSettingsPage() { setLeague(found); // Fetch series and settings - const [seriesRes, virtualMoneyRes, recruitingRes] = await Promise.all( - [ + const [seriesRes, virtualMoneyRes, recruitingRes, discordRes] = + await Promise.all([ fetch(`/api/leagues/${found.id}/series`, { cache: "no-store" }), fetch(`/api/leagues/${found.id}/virtual-money`, { cache: "no-store", @@ -116,8 +132,10 @@ export default function AdminSettingsPage() { fetch(`/api/leagues/${found.id}/recruiting`, { cache: "no-store", }), - ], - ); + fetch(`/api/leagues/${found.id}/discord-webhook`, { + cache: "no-store", + }), + ]); if (seriesRes.ok) { const seriesData = (await seriesRes.json()) as Series[]; @@ -145,6 +163,15 @@ export default function AdminSettingsPage() { ), ); } + + if (discordRes.ok) { + const discordData = + (await discordRes.json()) as DiscordWebhookConfig; + setDiscordWebhookUrl(discordData.webhookUrl ?? ""); + setDiscordOnEventCreated(discordData.onEventCreated); + setDiscordOnDayOfEvent(discordData.onDayOfEvent); + setDiscordOnResultsUploaded(discordData.onResultsUploaded); + } } } catch (err) { setError(err instanceof Error ? err.message : "unknown_error"); @@ -269,6 +296,50 @@ export default function AdminSettingsPage() { } }; + const handleSaveDiscordWebhook = async (sendTest = false) => { + if (!league) return; + + setSavingDiscord(true); + setDiscordNotice(null); + + try { + const response = await fetch( + `/api/leagues/${league.id}/discord-webhook`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + webhookUrl: discordWebhookUrl.trim() || null, + onEventCreated: discordOnEventCreated, + onDayOfEvent: discordOnDayOfEvent, + onResultsUploaded: discordOnResultsUploaded, + test: sendTest, + }), + }, + ); + + if (!response.ok) { + const err = (await response.json()) as { + message?: string; + error?: string; + }; + throw new Error(err.message ?? err.error ?? "failed_to_save"); + } + + setDiscordNotice( + sendTest + ? "Settings saved! A test notification has been sent to Discord." + : "Discord webhook settings saved successfully!", + ); + } catch (err) { + setDiscordNotice( + err instanceof Error ? err.message : "error_saving_discord_webhook", + ); + } finally { + setSavingDiscord(false); + } + }; + const toggleRecruitingSeries = (seriesId: string, checked: boolean) => { setRecruitingSeriesIds((prev) => { if (checked) { @@ -444,6 +515,99 @@ export default function AdminSettingsPage() { + {/* Discord Webhook Settings */} +
+
+

+ Discord Notifications + + Webhook + +

+

+ Post automatic notifications to a Discord channel via a + webhook URL. Paste the webhook URL from your Discord server + settings. +

+
+ + + +
+

+ Notify When +

+ + {( + [ + { + label: "New event added to the schedule", + value: discordOnEventCreated, + onChange: setDiscordOnEventCreated, + }, + { + label: "Day of a race event", + value: discordOnDayOfEvent, + onChange: setDiscordOnDayOfEvent, + }, + { + label: "Race results are uploaded", + value: discordOnResultsUploaded, + onChange: setDiscordOnResultsUploaded, + }, + ] as const + ).map(({ label, value, onChange }) => ( + + ))} +
+ + {discordNotice && ( +

+ {discordNotice} +

+ )} + +
+ + + {discordWebhookUrl.trim() && ( + + )} +
+
+ {/* Virtual Money Settings */}
diff --git a/src/app/page.tsx b/src/app/page.tsx index 4b65a78..96b95d4 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -31,9 +31,9 @@ const primaryFeatures = [ }, { icon: "πŸ”—", - title: "iRacing Connected", + title: "iRacing + Discord Connected", description: - "Sign in with iRacing and sync seasons, sessions, and race results directly into your league.", + "Sign in with iRacing, sync race data, and push Discord webhook alerts for event creation, race day, and uploaded results.", }, ]; @@ -44,6 +44,7 @@ const featureDetails = [ title: "Admin Controls", points: [ "Sync seasons and sessions from iRacing", + "Configure Discord webhooks per league", "Import, edit, and recalculate race results", "Assign bonus points, penalties, and provisionals", "Turn virtual money mode on or off anytime", @@ -70,7 +71,7 @@ const featureDetails = [ const quickStats = [ { label: "League Ops", value: "One place" }, { label: "Data Source", value: "iRacing" }, - { label: "Auth", value: "Secure OAuth" }, + { label: "Notifications", value: "Discord Webhooks" }, { label: "Virtual Economy", value: "Optional" }, ]; @@ -142,6 +143,11 @@ export default async function HomePage({ iRacing and built for serious championship management.

+

+ + Discord Webhook Notifications Now Supported +

+ {errorMessage && (

Login Error

diff --git a/src/lib/discord/webhook.ts b/src/lib/discord/webhook.ts new file mode 100644 index 0000000..0a81b78 --- /dev/null +++ b/src/lib/discord/webhook.ts @@ -0,0 +1,212 @@ +/** + * Discord webhook utilities for iRaceHub league notifications. + * Sends rich embeds to a configured Discord channel webhook. + */ + +interface DiscordEmbed { + title: string; + description?: string; + color?: number; + fields?: Array<{ name: string; value: string; inline?: boolean }>; + footer?: { text: string }; + timestamp?: string; +} + +interface DiscordWebhookPayload { + username?: string; + avatar_url?: string; + embeds: DiscordEmbed[]; +} + +// iRaceHub brand red +const COLOR_RED = 0xe53935; +const COLOR_GREEN = 0x43a047; +const COLOR_BLUE = 0x1e88e5; + +async function sendWebhook( + webhookUrl: string, + payload: DiscordWebhookPayload, +): Promise { + try { + const res = await fetch(webhookUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (!res.ok) { + const body = await res.text().catch(() => ""); + console.error( + `[Discord Webhook] Failed to send: HTTP ${res.status} - ${body}`, + ); + } + } catch (err) { + console.error("[Discord Webhook] Network error:", err); + } +} + +/** + * Notify Discord that a new event/race has been created on the schedule. + */ +export async function notifyEventCreated( + webhookUrl: string, + payload: { + leagueName: string; + seriesName: string; + raceName: string; + eventDate: Date; + trackName?: string | null; + raceLength?: string | null; + leagueUrl?: string | null; + }, +): Promise { + const dateStr = payload.eventDate.toLocaleDateString("en-US", { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + timeZone: "UTC", + }); + + const fields: DiscordEmbed["fields"] = [ + { name: "Series", value: payload.seriesName, inline: true }, + { name: "Date", value: dateStr, inline: true }, + ]; + + if (payload.trackName) { + fields.push({ name: "Track", value: payload.trackName, inline: true }); + } + + if (payload.raceLength) { + fields.push({ + name: "Race Length", + value: payload.raceLength, + inline: true, + }); + } + + await sendWebhook(webhookUrl, { + username: "iRaceHub", + embeds: [ + { + title: `πŸ“… New Event Added: ${payload.raceName}`, + description: `A new event has been added to the **${payload.leagueName}** schedule.`, + color: COLOR_BLUE, + fields, + timestamp: new Date().toISOString(), + footer: { text: "iRaceHub" }, + }, + ], + }); +} + +/** + * Notify Discord that today is race day. + */ +export async function notifyDayOfEvent( + webhookUrl: string, + payload: { + leagueName: string; + seriesName: string; + raceName: string; + eventDate: Date; + trackName?: string | null; + raceLength?: string | null; + registrationCount?: number; + }, +): Promise { + const fields: DiscordEmbed["fields"] = [ + { name: "Series", value: payload.seriesName, inline: true }, + { name: "Track", value: payload.trackName ?? "TBD", inline: true }, + ]; + + if (payload.raceLength) { + fields.push({ + name: "Race Length", + value: payload.raceLength, + inline: true, + }); + } + + if (payload.registrationCount != null) { + fields.push({ + name: "Registered Drivers", + value: String(payload.registrationCount), + inline: true, + }); + } + + await sendWebhook(webhookUrl, { + username: "iRaceHub", + embeds: [ + { + title: `🏁 Race Day: ${payload.raceName}`, + description: `Today is race day for **${payload.leagueName}**! Get ready to race.`, + color: COLOR_RED, + fields, + timestamp: new Date().toISOString(), + footer: { text: "iRaceHub" }, + }, + ], + }); +} + +/** + * Notify Discord that race results have been uploaded. + */ +export async function notifyResultsUploaded( + webhookUrl: string, + payload: { + leagueName: string; + seriesName: string; + raceName: string; + eventDate: Date; + trackName?: string | null; + winnerName?: string | null; + resultCount: number; + }, +): Promise { + const dateStr = payload.eventDate.toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + timeZone: "UTC", + }); + + const fields: DiscordEmbed["fields"] = [ + { name: "Series", value: payload.seriesName, inline: true }, + { name: "Date", value: dateStr, inline: true }, + ]; + + if (payload.trackName) { + fields.push({ name: "Track", value: payload.trackName, inline: true }); + } + + if (payload.winnerName) { + fields.push({ + name: "πŸ† Winner", + value: payload.winnerName, + inline: true, + }); + } + + fields.push({ + name: "Results", + value: `${payload.resultCount} driver${payload.resultCount !== 1 ? "s" : ""} classified`, + inline: true, + }); + + await sendWebhook(webhookUrl, { + username: "iRaceHub", + embeds: [ + { + title: `βœ… Results Posted: ${payload.raceName}`, + description: `Race results for **${payload.leagueName}** have been uploaded.`, + color: COLOR_GREEN, + fields, + timestamp: new Date().toISOString(), + footer: { text: "iRaceHub" }, + }, + ], + }); +}