Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
16 changes: 16 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Binary file added public/branding/iracehub-discord-banner.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
35 changes: 35 additions & 0 deletions public/branding/iracehub-discord-banner.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
128 changes: 128 additions & 0 deletions src/app/api/cron/day-of-event/route.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
178 changes: 178 additions & 0 deletions src/app/api/leagues/[leagueId]/discord-webhook/route.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
Loading
Loading