diff --git a/prisma/migrations/20260515143654_make_league_iracing_id_optional/migration.sql b/prisma/migrations/20260515143654_make_league_iracing_id_optional/migration.sql new file mode 100644 index 0000000..ede3b96 --- /dev/null +++ b/prisma/migrations/20260515143654_make_league_iracing_id_optional/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "leagues" ALTER COLUMN "iracing_league_id" DROP NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0c68a6a..9168767 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -25,7 +25,7 @@ model User { model League { id String @id @default(cuid()) - iracingLeagueId Int @unique @map("iracing_league_id") + iracingLeagueId Int? @unique @map("iracing_league_id") leagueName String @map("league_name") ownerCustId Int? @map("owner_cust_id") createdAtIracing DateTime? @map("created_at_iracing") diff --git a/src/app/api/drivers/[custId]/route.ts b/src/app/api/drivers/[custId]/route.ts index abbd738..2201a3a 100644 --- a/src/app/api/drivers/[custId]/route.ts +++ b/src/app/api/drivers/[custId]/route.ts @@ -199,7 +199,7 @@ export async function GET( string, { leagueId: string; - iracingLeagueId: number; + iracingLeagueId: number | null; leagueName: string; starts: number; wins: number; diff --git a/src/app/api/leagues/[leagueId]/iracing-link/route.ts b/src/app/api/leagues/[leagueId]/iracing-link/route.ts new file mode 100644 index 0000000..a0d2328 --- /dev/null +++ b/src/app/api/leagues/[leagueId]/iracing-link/route.ts @@ -0,0 +1,209 @@ +import { Prisma } from "@prisma/client"; +import { NextRequest, NextResponse } from "next/server"; +import { + fetchLeagueMembershipsFromIracing, + getIracingCustIdFromJwt, +} from "@/lib/auth/iracing"; +import { prisma } from "@/lib/prisma"; + +interface LinkLeagueBody { + iracingLeagueId?: number; +} + +function parseDateOrNull(value: string | undefined): Date | null { + if (!value) { + return null; + } + + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ leagueId: string }> }, +) { + const accessToken = request.cookies.get("irh_access_token")?.value; + if (!accessToken) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + + const { leagueId } = await params; + + let body: LinkLeagueBody; + try { + body = (await request.json()) as LinkLeagueBody; + } catch { + return NextResponse.json({ error: "invalid_json_body" }, { status: 400 }); + } + + if (!Number.isInteger(body.iracingLeagueId)) { + return NextResponse.json( + { error: "invalid_iracing_league_id" }, + { status: 400 }, + ); + } + + try { + const iracingCustId = getIracingCustIdFromJwt(accessToken); + + const user = await prisma.user.findUnique({ + where: { iracingCustId }, + select: { id: true }, + }); + + if (!user) { + return NextResponse.json({ error: "user_not_found" }, { status: 404 }); + } + + const league = await prisma.league.findUnique({ + where: { id: leagueId }, + select: { id: true }, + }); + + if (!league) { + return NextResponse.json({ error: "league_not_found" }, { 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 NextResponse.json( + { error: "forbidden_not_owner_or_admin" }, + { status: 403 }, + ); + } + + const memberships = await fetchLeagueMembershipsFromIracing(accessToken); + const selectedMembership = memberships.find( + (item) => + item.league_id === body.iracingLeagueId && + item.league && + (item.owner || item.admin), + ); + + if (!selectedMembership || !selectedMembership.league) { + return NextResponse.json( + { error: "forbidden_not_owner_or_admin_on_iracing_league" }, + { status: 403 }, + ); + } + + const existingLeague = await prisma.league.findUnique({ + where: { iracingLeagueId: body.iracingLeagueId }, + select: { id: true }, + }); + + if (existingLeague && existingLeague.id !== leagueId) { + return NextResponse.json( + { error: "iracing_league_already_linked" }, + { status: 409 }, + ); + } + + const leagueData = selectedMembership.league; + const rawLeagueJson = JSON.parse( + JSON.stringify(leagueData), + ) as Prisma.InputJsonValue; + + const updatedLeague = await prisma.$transaction(async (tx) => { + const updated = await tx.league.update({ + where: { id: leagueId }, + data: { + iracingLeagueId: selectedMembership.league_id, + leagueName: leagueData.league_name, + ownerCustId: leagueData.owner_cust_id, + createdAtIracing: parseDateOrNull(leagueData.created), + hidden: leagueData.hidden, + message: leagueData.message, + about: leagueData.about, + url: leagueData.url, + recruiting: leagueData.recruiting, + rules: leagueData.rules, + privateWall: leagueData.private_wall, + privateRoster: leagueData.private_roster, + privateSchedule: leagueData.private_schedule, + privateResults: leagueData.private_results, + rosterCount: leagueData.roster_count, + smallLogo: leagueData.small_logo, + largeLogo: leagueData.large_logo, + rawLeague: rawLeagueJson, + updatedAt: new Date(), + }, + }); + + await tx.leagueMembership.upsert({ + where: { + userId_leagueId: { + userId: user.id, + leagueId, + }, + }, + create: { + userId: user.id, + leagueId, + owner: selectedMembership.owner, + admin: selectedMembership.admin, + leagueMailOptOut: selectedMembership.league_mail_opt_out, + leaguePmOptOut: selectedMembership.league_pm_opt_out, + carNumber: selectedMembership.car_number, + nickName: selectedMembership.nick_name, + isMember: selectedMembership.is_member, + isApplicant: selectedMembership.is_applicant, + isInvite: selectedMembership.is_invite, + isIgnored: selectedMembership.is_ignored, + lastSyncedAt: new Date(), + }, + update: { + owner: selectedMembership.owner, + admin: selectedMembership.admin, + leagueMailOptOut: selectedMembership.league_mail_opt_out, + leaguePmOptOut: selectedMembership.league_pm_opt_out, + carNumber: selectedMembership.car_number, + nickName: selectedMembership.nick_name, + isMember: selectedMembership.is_member, + isApplicant: selectedMembership.is_applicant, + isInvite: selectedMembership.is_invite, + isIgnored: selectedMembership.is_ignored, + lastSyncedAt: new Date(), + }, + }); + + return updated; + }); + + return NextResponse.json({ + id: updatedLeague.id, + iracingLeagueId: updatedLeague.iracingLeagueId, + routeLeagueId: updatedLeague.iracingLeagueId + ? String(updatedLeague.iracingLeagueId) + : updatedLeague.id, + leagueName: updatedLeague.leagueName, + }); + } catch (err) { + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === "P2002" + ) { + return NextResponse.json( + { error: "iracing_league_already_linked" }, + { status: 409 }, + ); + } + + const message = err instanceof Error ? err.message : "unknown_error"; + console.error("Failed to link league to iRacing league:", message); + return NextResponse.json( + { error: "league_iracing_link_failed", message }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/leagues/[leagueId]/landing/route.ts b/src/app/api/leagues/[leagueId]/landing/route.ts index 2ff1999..f8c7471 100644 --- a/src/app/api/leagues/[leagueId]/landing/route.ts +++ b/src/app/api/leagues/[leagueId]/landing/route.ts @@ -198,18 +198,14 @@ export async function GET( return NextResponse.json({ error: "league_not_found" }, { status: 404 }); } - let authUser: - | { - id: string; - iracingCustId: number; - } - | null = null; - let membership: - | { - owner: boolean; - admin: boolean; - } - | null = null; + let authUser: { + id: string; + iracingCustId: number; + } | null = null; + let membership: { + owner: boolean; + admin: boolean; + } | null = null; if (accessToken) { try { @@ -228,7 +224,10 @@ export async function GET( }); } } catch (authError) { - console.warn("[league landing route] failed to resolve auth", authError); + console.warn( + "[league landing route] failed to resolve auth", + authError, + ); } } @@ -466,7 +465,12 @@ export async function GET( ); return NextResponse.json({ - league, + league: { + ...league, + routeLeagueId: league.iracingLeagueId + ? String(league.iracingLeagueId) + : league.id, + }, isAdmin, canSelfRegister: Boolean(membership && currentMember), series: seriesCards, diff --git a/src/app/api/leagues/[leagueId]/members/sync/route.ts b/src/app/api/leagues/[leagueId]/members/sync/route.ts index b0461ce..f6b6a07 100644 --- a/src/app/api/leagues/[leagueId]/members/sync/route.ts +++ b/src/app/api/leagues/[leagueId]/members/sync/route.ts @@ -76,6 +76,16 @@ export async function POST( return NextResponse.json({ error: "League not found" }, { status: 404 }); } + if (!Number.isInteger(league.iracingLeagueId)) { + return NextResponse.json( + { + error: + "League is not linked to an iRacing league yet. Link it first, then sync members.", + }, + { status: 400 }, + ); + } + // Fetch members from iRacing API const leagueData = await fetchIracingLinkedJson<{ roster?: IracingMember[]; diff --git a/src/app/api/leagues/[leagueId]/series/[seriesId]/seasons/sync/route.ts b/src/app/api/leagues/[leagueId]/series/[seriesId]/seasons/sync/route.ts index 39e91ba..7cc3112 100644 --- a/src/app/api/leagues/[leagueId]/series/[seriesId]/seasons/sync/route.ts +++ b/src/app/api/leagues/[leagueId]/series/[seriesId]/seasons/sync/route.ts @@ -115,6 +115,16 @@ export async function POST( return NextResponse.json({ error: "series_not_found" }, { status: 404 }); } + if (!Number.isInteger(series.league.iracingLeagueId)) { + return NextResponse.json( + { + error: + "League is not linked to an iRacing league yet. Link it first, then sync seasons.", + }, + { status: 400 }, + ); + } + const leagueSeasonsData = await fetchIracingLinkedJson<{ seasons?: Array<{ league_id: number; diff --git a/src/app/api/leagues/route.ts b/src/app/api/leagues/route.ts index b7e32db..99c0383 100644 --- a/src/app/api/leagues/route.ts +++ b/src/app/api/leagues/route.ts @@ -45,6 +45,9 @@ export async function GET(request: NextRequest) { const leagues = user.leagueMemberships.map((m) => ({ id: m.league.id, iracingLeagueId: m.league.iracingLeagueId, + routeLeagueId: m.league.iracingLeagueId + ? String(m.league.iracingLeagueId) + : m.league.id, leagueName: m.league.leagueName, smallLogo: m.league.smallLogo, rosterCount: m.league.rosterCount, @@ -66,6 +69,7 @@ export async function GET(request: NextRequest) { interface CreateLeagueBody { leagueId?: number; + leagueName?: string; } function parseDateOrNull(value: string | undefined): Date | null { @@ -91,8 +95,15 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "invalid_json_body" }, { status: 400 }); } - if (!Number.isInteger(body.leagueId)) { - return NextResponse.json({ error: "invalid_league_id" }, { status: 400 }); + const requestedLeagueName = + typeof body.leagueName === "string" ? body.leagueName.trim() : ""; + const isIracingCreate = Number.isInteger(body.leagueId); + + if (!isIracingCreate && !requestedLeagueName) { + return NextResponse.json( + { error: "invalid_create_league_payload" }, + { status: 400 }, + ); } try { @@ -107,132 +118,168 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "user_not_found" }, { status: 404 }); } - const memberships = await fetchLeagueMembershipsFromIracing(accessToken); + const createdLeague = isIracingCreate + ? await (async () => { + const memberships = + await fetchLeagueMembershipsFromIracing(accessToken); - // The membership endpoint is already scoped to the authenticated user. - // Matching on cust_id is unnecessary and can silently fail when the field - // comes back as a string or is absent in certain league types. - const selectedMembership = memberships.find( - (membership) => - membership.league_id === body.leagueId && - membership.league && - (membership.owner || membership.admin), - ); + const selectedMembership = memberships.find( + (membership) => + membership.league_id === body.leagueId && + membership.league && + (membership.owner || membership.admin), + ); - console.log( - "[league create] requested leagueId:", - body.leagueId, - "| matched membership:", - JSON.stringify(selectedMembership ?? null), - ); + console.log( + "[league create] requested leagueId:", + body.leagueId, + "| matched membership:", + JSON.stringify(selectedMembership ?? null), + ); - if (!selectedMembership || !selectedMembership.league) { - return NextResponse.json( - { error: "forbidden_not_owner_or_admin" }, - { status: 403 }, - ); - } + if (!selectedMembership || !selectedMembership.league) { + throw new Error("forbidden_not_owner_or_admin"); + } - const leagueData = selectedMembership.league; - const rawLeagueJson = JSON.parse( - JSON.stringify(leagueData), - ) as Prisma.InputJsonValue; + const leagueData = selectedMembership.league; + const rawLeagueJson = JSON.parse( + JSON.stringify(leagueData), + ) as Prisma.InputJsonValue; - const existingLeague = await prisma.league.findUnique({ - where: { iracingLeagueId: body.leagueId }, - select: { id: true }, - }); + const existingLeague = await prisma.league.findUnique({ + where: { iracingLeagueId: body.leagueId }, + select: { id: true }, + }); - if (existingLeague) { - return NextResponse.json( - { error: "league_already_exists" }, - { status: 409 }, - ); - } + if (existingLeague) { + throw new Error("league_already_exists"); + } - const createdLeague = await prisma.$transaction(async (tx) => { - const league = await tx.league.create({ - data: { - iracingLeagueId: selectedMembership.league_id, - leagueName: leagueData.league_name, - ownerCustId: leagueData.owner_cust_id, - createdAtIracing: parseDateOrNull(leagueData.created), - hidden: leagueData.hidden, - message: leagueData.message, - about: leagueData.about, - url: leagueData.url, - recruiting: leagueData.recruiting, - rules: leagueData.rules, - privateWall: leagueData.private_wall, - privateRoster: leagueData.private_roster, - privateSchedule: leagueData.private_schedule, - privateResults: leagueData.private_results, - rosterCount: leagueData.roster_count, - smallLogo: leagueData.small_logo, - largeLogo: leagueData.large_logo, - rawLeague: rawLeagueJson, - creatorUserId: user.id, - }, - }); + return prisma.$transaction(async (tx) => { + const league = await tx.league.create({ + data: { + iracingLeagueId: selectedMembership.league_id, + leagueName: leagueData.league_name, + ownerCustId: leagueData.owner_cust_id, + createdAtIracing: parseDateOrNull(leagueData.created), + hidden: leagueData.hidden, + message: leagueData.message, + about: leagueData.about, + url: leagueData.url, + recruiting: leagueData.recruiting, + rules: leagueData.rules, + privateWall: leagueData.private_wall, + privateRoster: leagueData.private_roster, + privateSchedule: leagueData.private_schedule, + privateResults: leagueData.private_results, + rosterCount: leagueData.roster_count, + smallLogo: leagueData.small_logo, + largeLogo: leagueData.large_logo, + rawLeague: rawLeagueJson, + creatorUserId: user.id, + }, + }); - await tx.leagueMembership.upsert({ - where: { - userId_leagueId: { - userId: user.id, - leagueId: league.id, - }, - }, - create: { - userId: user.id, - leagueId: league.id, - owner: selectedMembership.owner, - admin: selectedMembership.admin, - leagueMailOptOut: selectedMembership.league_mail_opt_out, - leaguePmOptOut: selectedMembership.league_pm_opt_out, - carNumber: selectedMembership.car_number, - nickName: selectedMembership.nick_name, - isMember: selectedMembership.is_member, - isApplicant: selectedMembership.is_applicant, - isInvite: selectedMembership.is_invite, - isIgnored: selectedMembership.is_ignored, - lastSyncedAt: new Date(), - }, - update: { - owner: selectedMembership.owner, - admin: selectedMembership.admin, - leagueMailOptOut: selectedMembership.league_mail_opt_out, - leaguePmOptOut: selectedMembership.league_pm_opt_out, - carNumber: selectedMembership.car_number, - nickName: selectedMembership.nick_name, - isMember: selectedMembership.is_member, - isApplicant: selectedMembership.is_applicant, - isInvite: selectedMembership.is_invite, - isIgnored: selectedMembership.is_ignored, - lastSyncedAt: new Date(), - }, - }); + await tx.leagueMembership.upsert({ + where: { + userId_leagueId: { + userId: user.id, + leagueId: league.id, + }, + }, + create: { + userId: user.id, + leagueId: league.id, + owner: selectedMembership.owner, + admin: selectedMembership.admin, + leagueMailOptOut: selectedMembership.league_mail_opt_out, + leaguePmOptOut: selectedMembership.league_pm_opt_out, + carNumber: selectedMembership.car_number, + nickName: selectedMembership.nick_name, + isMember: selectedMembership.is_member, + isApplicant: selectedMembership.is_applicant, + isInvite: selectedMembership.is_invite, + isIgnored: selectedMembership.is_ignored, + lastSyncedAt: new Date(), + }, + update: { + owner: selectedMembership.owner, + admin: selectedMembership.admin, + leagueMailOptOut: selectedMembership.league_mail_opt_out, + leaguePmOptOut: selectedMembership.league_pm_opt_out, + carNumber: selectedMembership.car_number, + nickName: selectedMembership.nick_name, + isMember: selectedMembership.is_member, + isApplicant: selectedMembership.is_applicant, + isInvite: selectedMembership.is_invite, + isIgnored: selectedMembership.is_ignored, + lastSyncedAt: new Date(), + }, + }); - await tx.userPermission.upsert({ - where: { - userId_permission: { - userId: user.id, - permission: PermissionType.ADMIN_ROUTES, - }, - }, - create: { - userId: user.id, - permission: PermissionType.ADMIN_ROUTES, - sourceLeagueId: league.id, - granted: true, - }, - update: { - sourceLeagueId: league.id, - granted: true, - }, - }); + await tx.userPermission.upsert({ + where: { + userId_permission: { + userId: user.id, + permission: PermissionType.ADMIN_ROUTES, + }, + }, + create: { + userId: user.id, + permission: PermissionType.ADMIN_ROUTES, + sourceLeagueId: league.id, + granted: true, + }, + update: { + sourceLeagueId: league.id, + granted: true, + }, + }); - return league; - }); + return league; + }); + })() + : await prisma.$transaction(async (tx) => { + const league = await tx.league.create({ + data: { + iracingLeagueId: null, + leagueName: requestedLeagueName, + creatorUserId: user.id, + }, + }); + + await tx.leagueMembership.create({ + data: { + userId: user.id, + leagueId: league.id, + owner: true, + admin: true, + isMember: true, + lastSyncedAt: new Date(), + }, + }); + + await tx.userPermission.upsert({ + where: { + userId_permission: { + userId: user.id, + permission: PermissionType.ADMIN_ROUTES, + }, + }, + create: { + userId: user.id, + permission: PermissionType.ADMIN_ROUTES, + sourceLeagueId: league.id, + granted: true, + }, + update: { + sourceLeagueId: league.id, + granted: true, + }, + }); + + return league; + }); console.info("[audit][league.create]", { timestamp: new Date().toISOString(), @@ -241,14 +288,33 @@ export async function POST(request: NextRequest) { createdLeagueId: createdLeague.id, iracingLeagueId: createdLeague.iracingLeagueId, leagueName: createdLeague.leagueName, + source: isIracingCreate ? "iracing" : "local", }); return NextResponse.json({ id: createdLeague.id, - leagueId: createdLeague.iracingLeagueId, + leagueId: createdLeague.iracingLeagueId ?? createdLeague.id, + iracingLeagueId: createdLeague.iracingLeagueId, leagueName: createdLeague.leagueName, }); } catch (err) { + if ( + err instanceof Error && + err.message === "forbidden_not_owner_or_admin" + ) { + return NextResponse.json( + { error: "forbidden_not_owner_or_admin" }, + { status: 403 }, + ); + } + + if (err instanceof Error && err.message === "league_already_exists") { + return NextResponse.json( + { error: "league_already_exists" }, + { status: 409 }, + ); + } + if ( err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002" diff --git a/src/app/app/[leagueId]/admin/page.tsx b/src/app/app/[leagueId]/admin/page.tsx index ab2602e..40c8f18 100644 --- a/src/app/app/[leagueId]/admin/page.tsx +++ b/src/app/app/[leagueId]/admin/page.tsx @@ -13,7 +13,8 @@ import { interface LeagueDetail { id: string; - iracingLeagueId: number; + iracingLeagueId: number | null; + routeLeagueId: string; leagueName: string; smallLogo: string | null; rosterCount: number | null; @@ -244,6 +245,8 @@ export default function LeagueAdminPage() { null, ); const [showVirtualMoneyModal, setShowVirtualMoneyModal] = useState(false); + const [pendingIracingLeagueId, setPendingIracingLeagueId] = useState(""); + const [linkingIracingLeague, setLinkingIracingLeague] = useState(false); // Results management // Schedule management @@ -277,7 +280,10 @@ export default function LeagueAdminPage() { const found = data.leagues?.find( - (l) => String(l.iracingLeagueId) === params.leagueId, + (l) => + l.id === params.leagueId || + l.routeLeagueId === params.leagueId || + String(l.iracingLeagueId) === params.leagueId, ) ?? null; if (!found) { @@ -822,7 +828,7 @@ export default function LeagueAdminPage() { const widgetOrigin = typeof window === "undefined" ? "" : window.location.origin; - const widgetLeagueId = league ? String(league.iracingLeagueId) : ""; + const widgetLeagueId = league ? league.routeLeagueId : ""; const widgetQueryParams = new URLSearchParams({ standingsLimit: String(standingsLimit), scheduleLimit: String(scheduleLimit), @@ -889,6 +895,60 @@ export default function LeagueAdminPage() { } }; + const handleLinkIracingLeague = async () => { + if (!league) return; + + const parsedLeagueId = Number.parseInt(pendingIracingLeagueId, 10); + if (!Number.isInteger(parsedLeagueId) || parsedLeagueId <= 0) { + alert("Enter a valid iRacing league ID."); + return; + } + + setLinkingIracingLeague(true); + try { + const response = await fetch(`/api/leagues/${league.id}/iracing-link`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ iracingLeagueId: parsedLeagueId }), + }); + + if (!response.ok) { + const message = await getApiErrorMessage( + response, + "failed_to_link_iracing_league", + ); + throw new Error(message); + } + + const linkedLeague = (await readJsonSafely<{ + iracingLeagueId: number | null; + routeLeagueId: string; + leagueName: string; + }>(response)) ?? { + iracingLeagueId: parsedLeagueId, + routeLeagueId: String(parsedLeagueId), + leagueName: league.leagueName, + }; + + setLeague((prev) => + prev + ? { + ...prev, + iracingLeagueId: linkedLeague.iracingLeagueId, + routeLeagueId: linkedLeague.routeLeagueId, + leagueName: linkedLeague.leagueName, + } + : prev, + ); + setPendingIracingLeagueId(""); + alert("League linked to iRacing successfully."); + } catch (err) { + alert(err instanceof Error ? err.message : "failed_to_link_iracing"); + } finally { + setLinkingIracingLeague(false); + } + }; + if (authLoading || loading) { return (
- iRacing League ID: {league.iracingLeagueId} + {league.iracingLeagueId != null + ? `iRacing League ID: ${league.iracingLeagueId}` + : "iRacing League: Not linked yet"} {league.rosterCount != null ? ` · ${league.rosterCount} members` : ""} @@ -1018,6 +1080,33 @@ export default function LeagueAdminPage() {
+ Once your league exists on iRacing, enter the iRacing league + ID here to enable member and season sync. +
+