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,2 @@
-- AlterTable
ALTER TABLE "leagues" ALTER COLUMN "iracing_league_id" DROP NOT NULL;
2 changes: 1 addition & 1 deletion prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/drivers/[custId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ export async function GET(
string,
{
leagueId: string;
iracingLeagueId: number;
iracingLeagueId: number | null;
leagueName: string;
starts: number;
wins: number;
Expand Down
209 changes: 209 additions & 0 deletions src/app/api/leagues/[leagueId]/iracing-link/route.ts
Original file line number Diff line number Diff line change
@@ -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 }> },
) {
Comment on lines +22 to +25
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,
Comment on lines +117 to +122
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 },
);
}
}
32 changes: 18 additions & 14 deletions src/app/api/leagues/[leagueId]/landing/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
);
}
}

Expand Down Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions src/app/api/leagues/[leagueId]/members/sync/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading