From b240a8a967b30d9d5c40e07675126142c659ded2 Mon Sep 17 00:00:00 2001 From: Darrell Richards Date: Fri, 15 May 2026 16:03:45 -0400 Subject: [PATCH] feat(added-support): adding test coverage and increased performance --- .vscode/tasks.json | 14 +- RACE_INFO_FEATURES.md | 240 + .../20260515155453_add/migration.sql | 74 + .../migration.sql | 3 + prisma/schema.prisma | 137 +- .../join-requests/[requestId]/route.ts | 181 + .../leagues/[leagueId]/join-requests/route.ts | 358 ++ .../leagues/[leagueId]/landing/route.test.ts | 250 +- .../api/leagues/[leagueId]/landing/route.ts | 93 +- .../leagues/[leagueId]/recruiting/route.ts | 227 + .../schedules/[scheduleId]/details/route.ts | 153 + .../[scheduleId]/registration/route.test.ts | 425 +- .../schedules/[scheduleId]/route.ts | 37 +- .../seasons/[seasonId]/schedules/route.ts | 35 +- .../sessions/[raceSessionId]/results/route.ts | 14 + .../seasons/[seasonId]/sessions/route.ts | 99 +- .../[leagueId]/sessions/lookup/route.ts | 79 + src/app/api/leagues/route.ts | 24 + src/app/app/[leagueId]/admin/dashboard.tsx | 331 ++ .../[leagueId]/admin/join-requests/page.tsx | 429 ++ src/app/app/[leagueId]/admin/members/page.tsx | 387 ++ src/app/app/[leagueId]/admin/page.tsx | 3164 +------------ src/app/app/[leagueId]/admin/series/page.tsx | 4039 +++++++++++++++++ .../app/[leagueId]/admin/settings/page.tsx | 605 +++ src/app/app/[leagueId]/admin/widgets/page.tsx | 521 +++ src/app/app/[leagueId]/landing-utils.test.ts | 250 + src/app/app/[leagueId]/landing-utils.ts | 285 ++ src/app/app/[leagueId]/page.tsx | 1853 ++++---- src/app/app/drivers/[custId]/page.tsx | 836 +++- src/app/dashboard/page.tsx | 29 +- src/components/AdminSidebar.tsx | 156 + src/components/RaceResultsModal.tsx | 395 +- vitest.config.ts | 9 + 33 files changed, 11579 insertions(+), 4153 deletions(-) create mode 100644 RACE_INFO_FEATURES.md create mode 100644 prisma/migrations/20260515155453_add/migration.sql create mode 100644 prisma/migrations/20260515191252_add_race_timing_fields/migration.sql create mode 100644 src/app/api/leagues/[leagueId]/join-requests/[requestId]/route.ts create mode 100644 src/app/api/leagues/[leagueId]/join-requests/route.ts create mode 100644 src/app/api/leagues/[leagueId]/recruiting/route.ts create mode 100644 src/app/api/leagues/[leagueId]/schedules/[scheduleId]/details/route.ts create mode 100644 src/app/api/leagues/[leagueId]/sessions/lookup/route.ts create mode 100644 src/app/app/[leagueId]/admin/dashboard.tsx create mode 100644 src/app/app/[leagueId]/admin/join-requests/page.tsx create mode 100644 src/app/app/[leagueId]/admin/members/page.tsx create mode 100644 src/app/app/[leagueId]/admin/series/page.tsx create mode 100644 src/app/app/[leagueId]/admin/settings/page.tsx create mode 100644 src/app/app/[leagueId]/admin/widgets/page.tsx create mode 100644 src/app/app/[leagueId]/landing-utils.test.ts create mode 100644 src/app/app/[leagueId]/landing-utils.ts create mode 100644 src/components/AdminSidebar.tsx diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 326d786..540e7cf 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -34,6 +34,18 @@ "type": "shell", "command": "npm run test", "isBackground": false + }, + { + "label": "Coverage Check", + "type": "shell", + "command": "npm run test:coverage", + "isBackground": false + }, + { + "label": "Coverage Verify", + "type": "shell", + "command": "npm run test:coverage", + "isBackground": false } ] -} +} \ No newline at end of file diff --git a/RACE_INFO_FEATURES.md b/RACE_INFO_FEATURES.md new file mode 100644 index 0000000..fb77d7b --- /dev/null +++ b/RACE_INFO_FEATURES.md @@ -0,0 +1,240 @@ +# Race Information & Registration Features + +## Overview + +The upcoming race display now includes: + +- ⚑ **Weather Information** - Current or forecasted weather conditions +- πŸšͺ **Room Open Time** - When the practice room will open +- 🏁 **Green Flag Time** - Official race start time +- πŸ† **Track Information** - Race venue and length +- πŸ‘₯ **Registration Status** - Number of registered drivers +- πŸ”₯ **Registration Button** - One-click registration for league members + +## Features Added + +### 1. Enhanced Featured Race Display + +The featured/next upcoming race is now prominently displayed on the league landing page with: + +- Eye-catching gradient border and shadow effects +- Real-time countdown indicator ("πŸ”₯ Imminent" when race is within 24 hours) +- All timing information in easy-to-read cards +- Registration button for eligible drivers +- Weather display with temperature, humidity, wind conditions + +### 2. Database Fields + +Two new DateTime fields were added to the `Schedule` model: + +- `roomOpenTime` - When drivers can enter the practice room +- `greenFlagTime` - Official race start time (can differ from `eventDate`) + +The `weather` field already existed and now displays detailed information including: + +- Type (Set or Realistic) +- Temperature +- Humidity +- Wind speed and direction +- Fog conditions +- Sky conditions + +### 3. Utility Functions + +New helper functions added to `landing-utils.ts`: + +- `formatWeather()` - Formats weather data into readable string +- `timeUntilEvent()` - Calculates time remaining until race with user-friendly labels +- Helper interfaces for type safety + +## Setting Up Race Information + +### For League Admins: Update Schedule Details + +Use the API endpoint to set weather and timing information: + +```bash +PATCH /api/leagues/{leagueId}/schedules/{scheduleId}/details +``` + +#### Request Body + +```json +{ + "weather": { + "type": "Realistic", + "temp": 72, + "humidity": 65, + "windSpeed": 8, + "windDirection": "NW", + "skies": "Partly Cloudy", + "fog": 0 + }, + "roomOpenTime": "2026-05-17T19:00:00Z", + "greenFlagTime": "2026-05-17T20:00:00Z" +} +``` + +#### Example cURL Request + +```bash +curl -X PATCH \ + "http://localhost:2300/api/leagues/your-league-id/schedules/your-schedule-id/details" \ + -H "Content-Type: application/json" \ + -H "Cookie: irh_access_token=YOUR_TOKEN" \ + -d '{ + "weather": { + "type": "Realistic", + "temp": 72, + "humidity": 65, + "windSpeed": 8, + "windDirection": "NW", + "skies": "Partly Cloudy" + }, + "roomOpenTime": "2026-05-17T19:00:00Z", + "greenFlagTime": "2026-05-17T20:00:00Z" + }' +``` + +### Using GraphQL (if implementing) + +You can extend the GraphQL API to accept these fields when creating/updating schedules. + +### Prisma Studio (Direct Database) + +```bash +npx prisma studio +``` + +Then navigate to the Schedule table and edit the fields directly. + +## Frontend Display + +### Weather Display + +The featured race card shows: + +- 🌀️ **Weather** - Temperature, conditions, humidity, wind +- Example: "72Β°F Partly Cloudy Β· 65% humid Β· NW 8 mph" + +### Timing Display + +- 🏁 **Green Flag**: Shows `greenFlagTime` or defaults to `eventDate` +- πŸšͺ **Room Opens**: Shows `roomOpenTime` and calculates minutes until race +- πŸ“… **Date**: Displayed for reference + +### Registration Section + +- Shows number of registered drivers +- For authenticated league members: + - **Register** button (green gradient) if not registered + - **Unregister** button (red) if already registered + - Disabled if registration is closed (within 20 min of event) + +## Data Fields + +### Weather Object Structure + +```typescript +interface WeatherData { + type?: "Set" | "Realistic"; + skies?: string; // e.g., "Clear", "Partly Cloudy", "Heavy Rain" + temp?: number; // Temperature in Fahrenheit + humidity?: number; // 0-100 + fog?: number; // 0-100 + windDirection?: string; // e.g., "NW", "E", "S" + windSpeed?: number; // mph +} +``` + +### Important Notes + +1. **Timestamps** use ISO 8601 format with UTC timezone +2. **greenFlagTime** is optional - if not set, displays `eventDate` +3. **roomOpenTime** is optional - if not set, doesn't display room info +4. **weather** can be partially filled - missing fields are hidden in display +5. All fields are nullable and can be set to `null` to clear them + +## Testing + +To test the features locally: + +1. Start the dev server: + +```bash +npm run dev +``` + +2. Navigate to a league page: `http://localhost:2300/app/your-league-id` + +3. You should see the enhanced featured race card with: + - Weather information + - Room open time + - Green flag time + - Registration button + +4. Try registering/unregistering if you're a league member + +## UI Styling + +The featured race card uses: + +- **Red gradient border** with glowing shadow effect +- **"⚑ Next Race"** badge +- **"πŸ”₯ Imminent"** badge (animated when race is within 24 hours) +- **Info cards** with semi-transparent backgrounds and backdrop blur +- **Action button** - green gradient for register, red for unregister +- **Status messages** - green for "You're registered", amber for warnings, red for errors + +## Future Enhancements + +Potential additions: + +1. **Admin UI** - Form to edit weather/timing without API calls +2. **Weather Integration** - Pull real weather from weather APIs +3. **Calendar Sync** - Show all race times in calendar format +4. **Notifications** - Alert drivers when room opens or race starts +5. **Multi-stage Weather** - Different weather for different stages of race +6. **Historical Weather** - Record actual weather that occurred +7. **Track Surface Temp** - Additional racing-specific weather data + +## Troubleshooting + +### Weather not displaying + +- Check that weather object is valid JSON in database +- Ensure at least one weather field is set (temp, skies, etc.) +- Verify weather is not an empty object `{}` + +### Timing shows wrong times + +- Verify timestamps are in correct ISO 8601 format +- Check timezone - system uses UTC internally +- Confirm `eventDate` exists if `greenFlagTime` is not set + +### Registration button not showing + +- Verify you're logged in +- Confirm you're a league member +- Check that registration is enabled and not closed +- Verify `registrationEnabled` field is `true` on schedule + +## API Response Format + +```typescript +{ + "id": "schedule-123", + "eventDate": "2026-05-17T20:00:00Z", + "raceName": "Monaco Grand Prix", + "weather": { + "type": "Realistic", + "temp": 72, + "humidity": 65, + "windSpeed": 8, + "windDirection": "NW", + "skies": "Partly Cloudy" + }, + "roomOpenTime": "2026-05-17T19:00:00Z", + "greenFlagTime": "2026-05-17T20:00:00Z" +} +``` diff --git a/prisma/migrations/20260515155453_add/migration.sql b/prisma/migrations/20260515155453_add/migration.sql new file mode 100644 index 0000000..4d6ec91 --- /dev/null +++ b/prisma/migrations/20260515155453_add/migration.sql @@ -0,0 +1,74 @@ +-- CreateEnum +CREATE TYPE "LeagueJoinRequestStatus" AS ENUM ('PENDING', 'APPROVED', 'DECLINED'); + +-- AlterTable +ALTER TABLE "leagues" ADD COLUMN "recruiting_open" BOOLEAN NOT NULL DEFAULT false; + +-- CreateTable +CREATE TABLE "league_recruiting_series" ( + "league_id" TEXT NOT NULL, + "series_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "league_recruiting_series_pkey" PRIMARY KEY ("league_id","series_id") +); + +-- CreateTable +CREATE TABLE "league_join_requests" ( + "id" TEXT NOT NULL, + "league_id" TEXT NOT NULL, + "requester_user_id" TEXT NOT NULL, + "requester_cust_id" INTEGER NOT NULL, + "full_name" TEXT NOT NULL, + "state" TEXT NOT NULL, + "country" TEXT NOT NULL, + "why_join" TEXT NOT NULL, + "status" "LeagueJoinRequestStatus" NOT NULL DEFAULT 'PENDING', + "reviewed_by_user_id" TEXT, + "reviewed_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "league_join_requests_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "league_join_request_series" ( + "request_id" TEXT NOT NULL, + "series_id" TEXT NOT NULL, + + CONSTRAINT "league_join_request_series_pkey" PRIMARY KEY ("request_id","series_id") +); + +-- CreateIndex +CREATE INDEX "league_recruiting_series_series_id_idx" ON "league_recruiting_series"("series_id"); + +-- CreateIndex +CREATE INDEX "league_join_requests_league_id_status_created_at_idx" ON "league_join_requests"("league_id", "status", "created_at"); + +-- CreateIndex +CREATE INDEX "league_join_requests_requester_user_id_league_id_idx" ON "league_join_requests"("requester_user_id", "league_id"); + +-- CreateIndex +CREATE INDEX "league_join_request_series_series_id_idx" ON "league_join_request_series"("series_id"); + +-- AddForeignKey +ALTER TABLE "league_recruiting_series" ADD CONSTRAINT "league_recruiting_series_league_id_fkey" FOREIGN KEY ("league_id") REFERENCES "leagues"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "league_recruiting_series" ADD CONSTRAINT "league_recruiting_series_series_id_fkey" FOREIGN KEY ("series_id") REFERENCES "series"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "league_join_requests" ADD CONSTRAINT "league_join_requests_league_id_fkey" FOREIGN KEY ("league_id") REFERENCES "leagues"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "league_join_requests" ADD CONSTRAINT "league_join_requests_requester_user_id_fkey" FOREIGN KEY ("requester_user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "league_join_requests" ADD CONSTRAINT "league_join_requests_reviewed_by_user_id_fkey" FOREIGN KEY ("reviewed_by_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "league_join_request_series" ADD CONSTRAINT "league_join_request_series_request_id_fkey" FOREIGN KEY ("request_id") REFERENCES "league_join_requests"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "league_join_request_series" ADD CONSTRAINT "league_join_request_series_series_id_fkey" FOREIGN KEY ("series_id") REFERENCES "series"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260515191252_add_race_timing_fields/migration.sql b/prisma/migrations/20260515191252_add_race_timing_fields/migration.sql new file mode 100644 index 0000000..ba18d5c --- /dev/null +++ b/prisma/migrations/20260515191252_add_race_timing_fields/migration.sql @@ -0,0 +1,3 @@ +-- Add race timing fields to schedules table +ALTER TABLE "schedules" ADD COLUMN "room_open_time" TIMESTAMP(3), +ADD COLUMN "green_flag_time" TIMESTAMP(3); \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9168767..892d69c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -18,7 +18,9 @@ model User { leagueMemberships LeagueMembership[] permissions UserPermission[] - createdLeagues League[] @relation("LeagueCreator") + createdLeagues League[] @relation("LeagueCreator") + joinRequests LeagueJoinRequest[] @relation("JoinRequestRequester") + reviewedRequests LeagueJoinRequest[] @relation("JoinRequestReviewer") @@map("users") } @@ -43,6 +45,7 @@ model League { smallLogo String? @map("small_logo") largeLogo String? @map("large_logo") rawLeague Json? @map("raw_league") + recruitingOpen Boolean @default(false) @map("recruiting_open") virtualModeEnabled Boolean @default(false) @map("virtual_mode_enabled") virtualBaselinePayout Json @default("[]") @map("virtual_baseline_payout") virtualEntryFee Int @default(0) @map("virtual_entry_fee") @@ -54,15 +57,17 @@ model League { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - creator User? @relation("LeagueCreator", fields: [creatorUserId], references: [id], onDelete: SetNull) - memberships LeagueMembership[] - permissions UserPermission[] - series Series[] - pointsSystems SeriesPointsSystem[] - members Member[] @relation("LeagueMembers") - teams Team[] - raceSessions RaceSession[] - moneyEvents VirtualMoneyEvent[] + creator User? @relation("LeagueCreator", fields: [creatorUserId], references: [id], onDelete: SetNull) + memberships LeagueMembership[] + permissions UserPermission[] + series Series[] + pointsSystems SeriesPointsSystem[] + members Member[] @relation("LeagueMembers") + teams Team[] + raceSessions RaceSession[] + moneyEvents VirtualMoneyEvent[] + recruitingSeries LeagueRecruitingSeries[] + joinRequests LeagueJoinRequest[] @@map("leagues") } @@ -145,16 +150,74 @@ model Series { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - league League @relation(fields: [leagueId], references: [id], onDelete: Cascade) - pointsSystem SeriesPointsSystem @relation(fields: [pointsSystemId], references: [id], onDelete: Restrict) - seasons Season[] - schedules Schedule[] - raceSessions RaceSession[] + league League @relation(fields: [leagueId], references: [id], onDelete: Cascade) + pointsSystem SeriesPointsSystem @relation(fields: [pointsSystemId], references: [id], onDelete: Restrict) + seasons Season[] + schedules Schedule[] + raceSessions RaceSession[] + recruitingLeagues LeagueRecruitingSeries[] + joinRequests LeagueJoinRequestSeries[] @@unique([leagueId, name]) @@map("series") } +enum LeagueJoinRequestStatus { + PENDING + APPROVED + DECLINED +} + +model LeagueRecruitingSeries { + leagueId String @map("league_id") + seriesId String @map("series_id") + createdAt DateTime @default(now()) @map("created_at") + + league League @relation(fields: [leagueId], references: [id], onDelete: Cascade) + series Series @relation(fields: [seriesId], references: [id], onDelete: Cascade) + + @@id([leagueId, seriesId]) + @@index([seriesId]) + @@map("league_recruiting_series") +} + +model LeagueJoinRequest { + id String @id @default(cuid()) + leagueId String @map("league_id") + requesterUserId String @map("requester_user_id") + requesterCustId Int @map("requester_cust_id") + fullName String @map("full_name") + state String + country String + whyJoin String @map("why_join") + status LeagueJoinRequestStatus @default(PENDING) + reviewedByUserId String? @map("reviewed_by_user_id") + reviewedAt DateTime? @map("reviewed_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + league League @relation(fields: [leagueId], references: [id], onDelete: Cascade) + requester User @relation("JoinRequestRequester", fields: [requesterUserId], references: [id], onDelete: Cascade) + reviewedBy User? @relation("JoinRequestReviewer", fields: [reviewedByUserId], references: [id], onDelete: SetNull) + requestedSeries LeagueJoinRequestSeries[] + + @@index([leagueId, status, createdAt]) + @@index([requesterUserId, leagueId]) + @@map("league_join_requests") +} + +model LeagueJoinRequestSeries { + requestId String @map("request_id") + seriesId String @map("series_id") + + request LeagueJoinRequest @relation(fields: [requestId], references: [id], onDelete: Cascade) + series Series @relation(fields: [seriesId], references: [id], onDelete: Cascade) + + @@id([requestId, seriesId]) + @@index([seriesId]) + @@map("league_join_request_series") +} + model Season { id String @id @default(cuid()) seriesId String @map("series_id") @@ -186,29 +249,31 @@ model Season { } model Schedule { - id String @id @default(cuid()) - seasonId String @map("season_id") - seriesId String @map("series_id") - eventDate DateTime @map("event_date") - raceName String @map("race_name") - isOffWeek Boolean @default(false) @map("is_off_week") - pointsCount Boolean @default(true) @map("points_count") - canDrop Boolean @default(false) @map("can_drop") - trackName String? @map("track_name") - trackId Int? @map("track_id") - raceLength String? @map("race_length") // e.g. "50 laps" or "1:30:00" - virtualPurse Int @default(0) @map("virtual_purse") - virtualEntryFee Int @default(0) @map("virtual_entry_fee") - virtualPayoutSplit Json @default("[]") @map("virtual_payout_split") + id String @id @default(cuid()) + seasonId String @map("season_id") + seriesId String @map("series_id") + eventDate DateTime @map("event_date") + raceName String @map("race_name") + isOffWeek Boolean @default(false) @map("is_off_week") + pointsCount Boolean @default(true) @map("points_count") + canDrop Boolean @default(false) @map("can_drop") + trackName String? @map("track_name") + trackId Int? @map("track_id") + raceLength String? @map("race_length") // e.g. "50 laps" or "1:30:00" + virtualPurse Int @default(0) @map("virtual_purse") + virtualEntryFee Int @default(0) @map("virtual_entry_fee") + virtualPayoutSplit Json @default("[]") @map("virtual_payout_split") // Stage configuration: [{ "stageNumber": 1, "endLap": 25 }, ...] - stages Json @default("[]") + stages Json @default("[]") // Weather data: { type: "Set"|"Realistic", skies?, temp?, humidity?, fog?, windDirection?, windSpeed? } - weather Json @default("{}") - raceOrder Int @default(0) @map("race_order") // Order within the season - registrationEnabled Boolean @default(true) @map("registration_enabled") - iracingSessionId Int? @unique @map("iracing_session_id") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + weather Json @default("{}") + roomOpenTime DateTime? @map("room_open_time") // When the practice room opens + greenFlagTime DateTime? @map("green_flag_time") // When the race officially starts + raceOrder Int @default(0) @map("race_order") // Order within the season + registrationEnabled Boolean @default(true) @map("registration_enabled") + iracingSessionId Int? @unique @map("iracing_session_id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") season Season @relation(fields: [seasonId], references: [id], onDelete: Cascade) series Series @relation(fields: [seriesId], references: [id], onDelete: Cascade) diff --git a/src/app/api/leagues/[leagueId]/join-requests/[requestId]/route.ts b/src/app/api/leagues/[leagueId]/join-requests/[requestId]/route.ts new file mode 100644 index 0000000..126b259 --- /dev/null +++ b/src/app/api/leagues/[leagueId]/join-requests/[requestId]/route.ts @@ -0,0 +1,181 @@ +import { LeagueJoinRequestStatus } from "@prisma/client"; +import { NextRequest, NextResponse } from "next/server"; +import { getIracingCustIdFromJwt } from "@/lib/auth/iracing"; +import { prisma } from "@/lib/prisma"; + +async function requireLeagueAdmin(leagueId: string, accessToken: string) { + const iracingCustId = getIracingCustIdFromJwt(accessToken); + + const user = await prisma.user.findUnique({ + where: { iracingCustId }, + select: { id: true }, + }); + + if (!user) { + return { + error: NextResponse.json({ error: "user_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 { + error: NextResponse.json( + { error: "forbidden_not_owner_or_admin" }, + { status: 403 }, + ), + }; + } + + return { userId: user.id }; +} + +interface UpdateJoinRequestBody { + action?: "approve" | "decline"; +} + +function buildIracingLeagueAdminUrl(league: { + url: string | null; + iracingLeagueId: number | null; +}) { + if (league.url && league.url.trim().length > 0) { + return league.url; + } + + if (league.iracingLeagueId != null) { + return `https://members-ng.iracing.com/leagues/${league.iracingLeagueId}`; + } + + return null; +} + +export async function PATCH( + request: NextRequest, + { + params, + }: { + params: Promise<{ leagueId: string; requestId: string }>; + }, +) { + const { leagueId, requestId } = await params; + const accessToken = request.cookies.get("irh_access_token")?.value; + + if (!accessToken) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + + let body: UpdateJoinRequestBody; + try { + body = (await request.json()) as UpdateJoinRequestBody; + } catch { + return NextResponse.json({ error: "invalid_json_body" }, { status: 400 }); + } + + if (body.action !== "approve" && body.action !== "decline") { + return NextResponse.json({ error: "invalid_action" }, { status: 400 }); + } + + try { + const auth = await requireLeagueAdmin(leagueId, accessToken); + if ("error" in auth) { + return auth.error; + } + + const joinRequest = await prisma.leagueJoinRequest.findFirst({ + where: { + id: requestId, + leagueId, + }, + select: { + id: true, + status: true, + requesterCustId: true, + fullName: true, + }, + }); + + if (!joinRequest) { + return NextResponse.json( + { error: "join_request_not_found" }, + { status: 404 }, + ); + } + + if (joinRequest.status !== LeagueJoinRequestStatus.PENDING) { + return NextResponse.json( + { error: "join_request_already_reviewed" }, + { status: 409 }, + ); + } + + const nextStatus = + body.action === "approve" + ? LeagueJoinRequestStatus.APPROVED + : LeagueJoinRequestStatus.DECLINED; + + const [updatedRequest, existingMember, league] = await prisma.$transaction([ + prisma.leagueJoinRequest.update({ + where: { id: joinRequest.id }, + data: { + status: nextStatus, + reviewedByUserId: auth.userId, + reviewedAt: new Date(), + }, + select: { + id: true, + status: true, + reviewedAt: true, + requesterCustId: true, + fullName: true, + }, + }), + prisma.member.findUnique({ + where: { + leagueId_custId: { + leagueId, + custId: joinRequest.requesterCustId, + }, + }, + select: { id: true }, + }), + prisma.league.findUnique({ + where: { id: leagueId }, + select: { + id: true, + iracingLeagueId: true, + url: true, + }, + }), + ]); + + if (!league) { + return NextResponse.json({ error: "league_not_found" }, { status: 404 }); + } + + const needsManualIracingAdd = + body.action === "approve" && existingMember == null; + + return NextResponse.json({ + request: updatedRequest, + needsManualIracingAdd, + iracingLeagueAdminUrl: needsManualIracingAdd + ? buildIracingLeagueAdminUrl(league) + : null, + }); + } catch (error) { + console.error("[join-requests.patch]", error); + return NextResponse.json( + { error: "internal_server_error" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/leagues/[leagueId]/join-requests/route.ts b/src/app/api/leagues/[leagueId]/join-requests/route.ts new file mode 100644 index 0000000..4daf28c --- /dev/null +++ b/src/app/api/leagues/[leagueId]/join-requests/route.ts @@ -0,0 +1,358 @@ +import { LeagueJoinRequestStatus } from "@prisma/client"; +import { NextRequest, NextResponse } from "next/server"; +import { getIracingCustIdFromJwt } from "@/lib/auth/iracing"; +import { prisma } from "@/lib/prisma"; + +async function getAuthenticatedUser(accessToken: string) { + const iracingCustId = getIracingCustIdFromJwt(accessToken); + + return prisma.user.findUnique({ + where: { iracingCustId }, + select: { + id: true, + iracingCustId: true, + displayName: true, + country: true, + }, + }); +} + +async function requireLeagueAdmin(leagueId: string, accessToken: string) { + const user = await getAuthenticatedUser(accessToken); + + if (!user) { + return { + error: NextResponse.json({ error: "user_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 { + error: NextResponse.json( + { error: "forbidden_not_owner_or_admin" }, + { status: 403 }, + ), + }; + } + + return { user }; +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ leagueId: string }> }, +) { + const { leagueId } = await params; + const accessToken = request.cookies.get("irh_access_token")?.value; + + if (!accessToken) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + + try { + const auth = await requireLeagueAdmin(leagueId, accessToken); + if ("error" in auth) { + return auth.error; + } + + const [league, requests] = await Promise.all([ + prisma.league.findUnique({ + where: { id: leagueId }, + select: { + id: true, + leagueName: true, + iracingLeagueId: true, + url: true, + }, + }), + prisma.leagueJoinRequest.findMany({ + where: { leagueId }, + include: { + requestedSeries: { + include: { + series: { + select: { + id: true, + name: true, + }, + }, + }, + }, + reviewedBy: { + select: { + id: true, + displayName: true, + iracingCustId: true, + }, + }, + }, + orderBy: { createdAt: "desc" }, + }), + ]); + + if (!league) { + return NextResponse.json({ error: "league_not_found" }, { status: 404 }); + } + + const requestsWithMembership = await Promise.all( + requests.map(async (requestRow) => { + const member = await prisma.member.findUnique({ + where: { + leagueId_custId: { + leagueId, + custId: requestRow.requesterCustId, + }, + }, + select: { id: true }, + }); + + return { + id: requestRow.id, + requesterCustId: requestRow.requesterCustId, + fullName: requestRow.fullName, + state: requestRow.state, + country: requestRow.country, + whyJoin: requestRow.whyJoin, + status: requestRow.status, + createdAt: requestRow.createdAt, + updatedAt: requestRow.updatedAt, + reviewedAt: requestRow.reviewedAt, + reviewedBy: requestRow.reviewedBy, + requestedSeries: requestRow.requestedSeries.map( + (entry) => entry.series, + ), + isLeagueMember: Boolean(member), + }; + }), + ); + + const statusWeight: Record = { + PENDING: 0, + APPROVED: 1, + DECLINED: 2, + }; + + requestsWithMembership.sort((a, b) => { + const weightDiff = statusWeight[a.status] - statusWeight[b.status]; + if (weightDiff !== 0) { + return weightDiff; + } + + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + }); + + return NextResponse.json({ + league, + requests: requestsWithMembership, + }); + } catch (error) { + console.error("[join-requests.get]", error); + return NextResponse.json( + { error: "internal_server_error" }, + { status: 500 }, + ); + } +} + +interface CreateJoinRequestBody { + iracingId?: number; + fullName?: string; + state?: string; + country?: string; + whyJoin?: string; + seriesIds?: string[]; +} + +const trimToNull = (value: unknown) => { + if (typeof value !== "string") { + return null; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +}; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ leagueId: string }> }, +) { + const { leagueId } = await params; + const accessToken = request.cookies.get("irh_access_token")?.value; + + if (!accessToken) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + + let body: CreateJoinRequestBody; + try { + body = (await request.json()) as CreateJoinRequestBody; + } catch { + return NextResponse.json({ error: "invalid_json_body" }, { status: 400 }); + } + + const fullName = trimToNull(body.fullName); + const state = trimToNull(body.state); + const country = trimToNull(body.country); + const whyJoin = trimToNull(body.whyJoin); + const selectedSeriesIds = + body.seriesIds?.filter( + (seriesId): seriesId is string => + typeof seriesId === "string" && seriesId.trim().length > 0, + ) ?? []; + const uniqueSeriesIds = Array.from(new Set(selectedSeriesIds)); + + if (!fullName || !state || !country || !whyJoin) { + return NextResponse.json( + { error: "missing_required_fields" }, + { status: 400 }, + ); + } + + if (uniqueSeriesIds.length === 0) { + return NextResponse.json( + { error: "series_selection_required" }, + { status: 400 }, + ); + } + + try { + const user = await getAuthenticatedUser(accessToken); + + if (!user) { + return NextResponse.json({ error: "user_not_found" }, { status: 404 }); + } + + const [league, existingMember, pendingRequest] = await Promise.all([ + prisma.league.findUnique({ + where: { id: leagueId }, + select: { + id: true, + recruitingOpen: true, + recruitingSeries: { + select: { + seriesId: true, + }, + }, + }, + }), + prisma.member.findUnique({ + where: { + leagueId_custId: { + leagueId, + custId: user.iracingCustId, + }, + }, + select: { id: true }, + }), + prisma.leagueJoinRequest.findFirst({ + where: { + leagueId, + requesterUserId: user.id, + status: LeagueJoinRequestStatus.PENDING, + }, + select: { id: true }, + }), + ]); + + if (!league) { + return NextResponse.json({ error: "league_not_found" }, { status: 404 }); + } + + if (!league.recruitingOpen) { + return NextResponse.json({ error: "recruiting_closed" }, { status: 403 }); + } + + if (existingMember) { + return NextResponse.json({ error: "already_member" }, { status: 409 }); + } + + if (pendingRequest) { + return NextResponse.json( + { error: "pending_request_exists" }, + { status: 409 }, + ); + } + + const openSeriesIdSet = new Set( + league.recruitingSeries.map((entry) => entry.seriesId), + ); + + if (openSeriesIdSet.size === 0) { + return NextResponse.json( + { error: "no_series_open_for_recruiting" }, + { status: 400 }, + ); + } + + const hasInvalidSeries = uniqueSeriesIds.some( + (seriesId) => !openSeriesIdSet.has(seriesId), + ); + + if (hasInvalidSeries) { + return NextResponse.json( + { error: "invalid_series_selection" }, + { status: 400 }, + ); + } + + const created = await prisma.leagueJoinRequest.create({ + data: { + leagueId, + requesterUserId: user.id, + requesterCustId: user.iracingCustId, + fullName, + state, + country, + whyJoin, + requestedSeries: { + createMany: { + data: uniqueSeriesIds.map((seriesId) => ({ seriesId })), + }, + }, + }, + include: { + requestedSeries: { + include: { + series: { + select: { + id: true, + name: true, + }, + }, + }, + }, + }, + }); + + return NextResponse.json( + { + id: created.id, + status: created.status, + requesterCustId: created.requesterCustId, + fullName: created.fullName, + state: created.state, + country: created.country, + whyJoin: created.whyJoin, + requestedSeries: created.requestedSeries.map((entry) => entry.series), + createdAt: created.createdAt, + }, + { status: 201 }, + ); + } catch (error) { + console.error("[join-requests.post]", error); + return NextResponse.json( + { error: "internal_server_error" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/leagues/[leagueId]/landing/route.test.ts b/src/app/api/leagues/[leagueId]/landing/route.test.ts index e271596..6d8d782 100644 --- a/src/app/api/leagues/[leagueId]/landing/route.test.ts +++ b/src/app/api/leagues/[leagueId]/landing/route.test.ts @@ -15,9 +15,21 @@ const mocks = vi.hoisted(() => ({ member: { findUnique: vi.fn(), }, + leagueJoinRequest: { + findFirst: vi.fn(), + }, series: { findMany: vi.fn(), }, + schedule: { + findFirst: vi.fn(), + }, + raceSession: { + findFirst: vi.fn(), + }, + raceSessionResult: { + findMany: vi.fn(), + }, }, getIracingCustIdFromJwt: vi.fn(), })); @@ -44,9 +56,7 @@ function buildRequest(accessToken = "token"): NextRequest { } as unknown as NextRequest; } -function mockBaseLeagueAndUser() { - mocks.getIracingCustIdFromJwt.mockReturnValue(12345); - +function mockLeague(overrides?: Partial) { mocks.prisma.league.findUnique.mockResolvedValue({ id: "league-1", iracingLeagueId: 101, @@ -56,27 +66,66 @@ function mockBaseLeagueAndUser() { rosterCount: 10, about: null, message: null, - virtualModeEnabled: false, + recruitingOpen: true, + recruitingSeries: [ + { + series: { + id: "s-1", + name: "GT3", + }, + }, + ], + virtualModeEnabled: true, virtualEntryFee: 0, + ...overrides, }); +} +function mockAuthUser(admin = false, memberSynced = true) { + mocks.getIracingCustIdFromJwt.mockReturnValue(12345); mocks.prisma.user.findUnique.mockResolvedValue({ id: "user-1", iracingCustId: 12345, + displayName: "Driver One", + country: "US", }); - - mocks.prisma.series.findMany.mockResolvedValue([]); + mocks.prisma.leagueMembership.findUnique.mockResolvedValue({ + owner: false, + admin, + }); + mocks.prisma.member.findUnique.mockResolvedValue( + memberSynced + ? { + id: "member-1", + displayName: "Driver One", + } + : null, + ); } describe("GET /api/leagues/[leagueId]/landing", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.prisma.series.findMany.mockResolvedValue([]); + mocks.prisma.leagueJoinRequest.findFirst.mockResolvedValue(null); + }); + + it("returns 404 when league does not exist", async () => { + mocks.prisma.league.findUnique.mockResolvedValue(null); + + const response = await GET(buildRequest(), { + params: Promise.resolve({ leagueId: "missing" }), + }); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toEqual({ + error: "league_not_found", + }); }); it("returns 200 for authenticated non-members and disables self-registration", async () => { - mockBaseLeagueAndUser(); - mocks.prisma.leagueMembership.findUnique.mockResolvedValue(null); - mocks.prisma.member.findUnique.mockResolvedValue(null); + mockLeague(); + mockAuthUser(false, false); const response = await GET(buildRequest(), { params: Promise.resolve({ leagueId: "league-1" }), @@ -87,24 +136,19 @@ describe("GET /api/leagues/[leagueId]/landing", () => { const payload = (await response.json()) as { isAdmin: boolean; canSelfRegister: boolean; + isLeagueMember: boolean; series: unknown[]; }; expect(payload.isAdmin).toBe(false); expect(payload.canSelfRegister).toBe(false); + expect(payload.isLeagueMember).toBe(false); expect(payload.series).toEqual([]); }); it("keeps admin and self-registration enabled for valid members", async () => { - mockBaseLeagueAndUser(); - mocks.prisma.leagueMembership.findUnique.mockResolvedValue({ - owner: false, - admin: true, - }); - mocks.prisma.member.findUnique.mockResolvedValue({ - id: "member-1", - displayName: "Driver One", - }); + mockLeague(); + mockAuthUser(true, true); const response = await GET(buildRequest(), { params: Promise.resolve({ leagueId: "league-1" }), @@ -115,27 +159,183 @@ describe("GET /api/leagues/[leagueId]/landing", () => { const payload = (await response.json()) as { isAdmin: boolean; canSelfRegister: boolean; + isLeagueMember: boolean; + viewer: { iracingCustId: number } | null; }; expect(payload.isAdmin).toBe(true); expect(payload.canSelfRegister).toBe(true); + expect(payload.isLeagueMember).toBe(true); + expect(payload.viewer?.iracingCustId).toBe(12345); }); - it("returns 200 when access token is missing and keeps actions disabled", async () => { - mockBaseLeagueAndUser(); + it("returns 200 when access token decode fails", async () => { + mockLeague(); + mocks.getIracingCustIdFromJwt.mockImplementation(() => { + throw new Error("bad token"); + }); - const response = await GET(buildRequest(""), { + const response = await GET(buildRequest(), { params: Promise.resolve({ leagueId: "league-1" }), }); expect(response.status).toBe(200); - const payload = (await response.json()) as { isAdmin: boolean; - canSelfRegister: boolean; + viewer: unknown; }; - expect(payload.isAdmin).toBe(false); - expect(payload.canSelfRegister).toBe(false); + expect(payload.viewer).toBeNull(); + }); + + it("builds next event, last race, standings, and registration visibility", async () => { + mockLeague({ virtualModeEnabled: true }); + mockAuthUser(true, true); + + mocks.prisma.leagueJoinRequest.findFirst.mockResolvedValue({ + id: "jr-1", + status: "PENDING", + createdAt: new Date("2099-01-01T00:00:00.000Z"), + requestedSeries: [{ series: { id: "s-1", name: "GT3" } }], + }); + + mocks.prisma.series.findMany.mockResolvedValue([ + { + id: "series-1", + name: "GT3 Series", + description: "desc", + seasons: [ + { + id: "season-1", + seasonName: "Season 1", + description: "S1", + iracingSeasonId: 55, + }, + ], + }, + ]); + + mocks.prisma.schedule.findFirst.mockResolvedValue({ + id: "schedule-1", + eventDate: new Date("2099-02-01T00:00:00.000Z"), + raceName: "Race 1", + isOffWeek: false, + pointsCount: true, + canDrop: false, + registrationEnabled: true, + trackName: "Road Atlanta", + trackId: 1, + raceLength: "30 laps", + raceOrder: 1, + iracingSessionId: null, + weather: {}, + roomOpenTime: null, + greenFlagTime: null, + importedSession: null, + registrations: [ + { + id: "reg-1", + memberId: "member-1", + createdAt: new Date("2099-01-31T00:00:00.000Z"), + member: { + id: "member-1", + custId: 12345, + displayName: "Driver One", + carNumber: "7", + nickName: "One", + }, + }, + ], + }); + + mocks.prisma.raceSession.findFirst.mockResolvedValue({ + id: "rs-1", + launchAt: new Date("2099-01-01T00:00:00.000Z"), + trackName: "undefined", + winnerName: "Winner", + winnerCustId: 12345, + iracingSessionId: 1, + subsessionId: 2, + schedule: { + id: "schedule-last", + raceName: "Last Race", + eventDate: new Date("2099-01-01T00:00:00.000Z"), + raceOrder: 3, + trackName: "Spa", + virtualPayoutSplit: [100, 50, 25], + }, + results: [ + { + id: "r-1", + custId: 12345, + displayName: "Driver One", + finishPosition: 1, + startPosition: 2, + lapsCompleted: 20, + incidents: 0, + finalPoints: 55, + provisional: false, + }, + ], + }); + + mocks.prisma.raceSessionResult.findMany.mockResolvedValue([ + { + custId: 12345, + displayName: "Driver One", + finalPoints: 90, + finishPosition: 1, + }, + { + custId: 54321, + displayName: "Driver Two", + finalPoints: 70, + finishPosition: 2, + }, + ]); + + const response = await GET(buildRequest(), { + params: Promise.resolve({ leagueId: "league-1" }), + }); + + expect(response.status).toBe(200); + + const payload = (await response.json()) as { + currentJoinRequest: { id: string } | null; + series: Array<{ + nextEvent: { + registrationCount: number; + isRegisteredByMe: boolean; + } | null; + lastRaceResult: { + trackName: string | null; + results: Array<{ virtualEarnings: number | null }>; + } | null; + standings: Array<{ gapToLeader: number }>; + }>; + }; + + expect(payload.currentJoinRequest?.id).toBe("jr-1"); + expect(payload.series).toHaveLength(1); + expect(payload.series[0]?.nextEvent?.registrationCount).toBe(1); + expect(payload.series[0]?.nextEvent?.isRegisteredByMe).toBe(true); + expect(payload.series[0]?.lastRaceResult?.trackName).toBe("Spa"); + expect(payload.series[0]?.lastRaceResult?.results[0]?.virtualEarnings).toBe( + 100, + ); + expect(payload.series[0]?.standings[0]?.gapToLeader).toBe(0); + }); + + it("returns 500 when an unexpected error occurs", async () => { + mocks.prisma.league.findUnique.mockRejectedValue(new Error("db failed")); + + const response = await GET(buildRequest(), { + params: Promise.resolve({ leagueId: "league-1" }), + }); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toEqual({ + error: "failed_to_load_landing", + }); }); }); diff --git a/src/app/api/leagues/[leagueId]/landing/route.ts b/src/app/api/leagues/[leagueId]/landing/route.ts index f8c7471..8c817d3 100644 --- a/src/app/api/leagues/[leagueId]/landing/route.ts +++ b/src/app/api/leagues/[leagueId]/landing/route.ts @@ -174,6 +174,18 @@ export async function GET( rosterCount: true, about: true, message: true, + recruitingOpen: true, + recruitingSeries: { + select: { + series: { + select: { + id: true, + name: true, + }, + }, + }, + orderBy: { series: { name: "asc" } }, + }, virtualModeEnabled: true, virtualEntryFee: true, }, @@ -189,6 +201,18 @@ export async function GET( rosterCount: true, about: true, message: true, + recruitingOpen: true, + recruitingSeries: { + select: { + series: { + select: { + id: true, + name: true, + }, + }, + }, + orderBy: { series: { name: "asc" } }, + }, virtualModeEnabled: true, virtualEntryFee: true, }, @@ -201,6 +225,8 @@ export async function GET( let authUser: { id: string; iracingCustId: number; + displayName: string | null; + country: string | null; } | null = null; let membership: { owner: boolean; @@ -212,7 +238,12 @@ export async function GET( const iracingCustId = getIracingCustIdFromJwt(accessToken); authUser = await prisma.user.findUnique({ where: { iracingCustId }, - select: { id: true, iracingCustId: true }, + select: { + id: true, + iracingCustId: true, + displayName: true, + country: true, + }, }); if (authUser) { @@ -233,7 +264,7 @@ export async function GET( const isAdmin = Boolean(membership?.owner || membership?.admin); - const [currentMember, series] = await Promise.all([ + const [currentMember, currentJoinRequest, series] = await Promise.all([ authUser ? prisma.member.findUnique({ where: { @@ -245,6 +276,30 @@ export async function GET( select: { id: true, displayName: true }, }) : Promise.resolve(null), + authUser + ? prisma.leagueJoinRequest.findFirst({ + where: { + leagueId: league.id, + requesterUserId: authUser.id, + status: "PENDING", + }, + select: { + id: true, + status: true, + createdAt: true, + requestedSeries: { + select: { + series: { + select: { + id: true, + name: true, + }, + }, + }, + }, + }, + }) + : Promise.resolve(null), prisma.series.findMany({ where: { leagueId: league.id, isActive: true }, orderBy: { name: "asc" }, @@ -321,6 +376,9 @@ export async function GET( raceLength: true, raceOrder: true, iracingSessionId: true, + weather: true, + roomOpenTime: true, + greenFlagTime: true, importedSession: { select: { id: true, @@ -466,13 +524,42 @@ export async function GET( return NextResponse.json({ league: { - ...league, + id: league.id, + iracingLeagueId: league.iracingLeagueId, + leagueName: league.leagueName, + smallLogo: league.smallLogo, + largeLogo: league.largeLogo, + rosterCount: league.rosterCount, + about: league.about, + message: league.message, routeLeagueId: league.iracingLeagueId ? String(league.iracingLeagueId) : league.id, + recruiting: { + open: league.recruitingOpen, + series: league.recruitingSeries.map((entry) => entry.series), + }, }, isAdmin, canSelfRegister: Boolean(membership && currentMember), + isLeagueMember: Boolean(currentMember), + viewer: authUser + ? { + iracingCustId: authUser.iracingCustId, + displayName: authUser.displayName, + country: authUser.country, + } + : null, + currentJoinRequest: currentJoinRequest + ? { + id: currentJoinRequest.id, + status: currentJoinRequest.status, + createdAt: currentJoinRequest.createdAt, + requestedSeries: currentJoinRequest.requestedSeries.map( + (entry) => entry.series, + ), + } + : null, series: seriesCards, }); } catch (error) { diff --git a/src/app/api/leagues/[leagueId]/recruiting/route.ts b/src/app/api/leagues/[leagueId]/recruiting/route.ts new file mode 100644 index 0000000..e480ed7 --- /dev/null +++ b/src/app/api/leagues/[leagueId]/recruiting/route.ts @@ -0,0 +1,227 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getIracingCustIdFromJwt } from "@/lib/auth/iracing"; +import { prisma } from "@/lib/prisma"; + +async function requireLeagueAdmin(leagueId: string, accessToken: string) { + const iracingCustId = getIracingCustIdFromJwt(accessToken); + + const user = await prisma.user.findUnique({ + where: { iracingCustId }, + select: { id: true }, + }); + + if (!user) { + return { + error: NextResponse.json({ error: "user_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 { + error: NextResponse.json( + { error: "forbidden_not_owner_or_admin" }, + { status: 403 }, + ), + }; + } + + return { userId: user.id }; +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ leagueId: string }> }, +) { + const { leagueId } = await params; + const accessToken = request.cookies.get("irh_access_token")?.value; + + if (!accessToken) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + + try { + const auth = await requireLeagueAdmin(leagueId, accessToken); + if ("error" in auth) { + return auth.error; + } + + const [league, allSeries] = await Promise.all([ + prisma.league.findUnique({ + where: { id: leagueId }, + select: { + id: true, + recruitingOpen: true, + recruitingSeries: { + select: { + series: { + select: { + id: true, + name: true, + }, + }, + }, + orderBy: { series: { name: "asc" } }, + }, + }, + }), + prisma.series.findMany({ + where: { leagueId, isActive: true }, + select: { id: true, name: true }, + orderBy: { name: "asc" }, + }), + ]); + + if (!league) { + return NextResponse.json({ error: "league_not_found" }, { status: 404 }); + } + + return NextResponse.json({ + id: league.id, + recruitingOpen: league.recruitingOpen, + openSeries: league.recruitingSeries.map((entry) => entry.series), + availableSeries: allSeries, + }); + } catch (error) { + console.error("[recruiting.get]", error); + return NextResponse.json( + { error: "internal_server_error" }, + { status: 500 }, + ); + } +} + +interface UpdateRecruitingBody { + recruitingOpen?: boolean; + openSeriesIds?: string[]; +} + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ leagueId: string }> }, +) { + const { leagueId } = await params; + const accessToken = request.cookies.get("irh_access_token")?.value; + + if (!accessToken) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + + let body: UpdateRecruitingBody; + try { + body = (await request.json()) as UpdateRecruitingBody; + } catch { + return NextResponse.json({ error: "invalid_json_body" }, { status: 400 }); + } + + if (typeof body.recruitingOpen !== "boolean") { + return NextResponse.json( + { error: "invalid_recruiting_open" }, + { status: 400 }, + ); + } + + const selectedSeriesIds = + body.openSeriesIds?.filter( + (seriesId): seriesId is string => + typeof seriesId === "string" && seriesId.trim().length > 0, + ) ?? []; + + const uniqueSeriesIds = Array.from(new Set(selectedSeriesIds)); + + try { + const auth = await requireLeagueAdmin(leagueId, accessToken); + if ("error" in auth) { + return auth.error; + } + + const league = await prisma.league.findUnique({ + where: { id: leagueId }, + select: { id: true }, + }); + + if (!league) { + return NextResponse.json({ error: "league_not_found" }, { status: 404 }); + } + + if (uniqueSeriesIds.length > 0) { + const matchingSeries = await prisma.series.count({ + where: { + leagueId, + isActive: true, + id: { in: uniqueSeriesIds }, + }, + }); + + if (matchingSeries !== uniqueSeriesIds.length) { + return NextResponse.json( + { error: "invalid_open_series_ids" }, + { status: 400 }, + ); + } + } + + const updated = await prisma.$transaction(async (tx) => { + await tx.league.update({ + where: { id: leagueId }, + data: { + recruitingOpen: body.recruitingOpen, + }, + }); + + await tx.leagueRecruitingSeries.deleteMany({ + where: { leagueId }, + }); + + if (uniqueSeriesIds.length > 0) { + await tx.leagueRecruitingSeries.createMany({ + data: uniqueSeriesIds.map((seriesId) => ({ leagueId, seriesId })), + }); + } + + return tx.league.findUnique({ + where: { id: leagueId }, + select: { + id: true, + recruitingOpen: true, + recruitingSeries: { + select: { + series: { + select: { + id: true, + name: true, + }, + }, + }, + orderBy: { series: { name: "asc" } }, + }, + }, + }); + }); + + if (!updated) { + return NextResponse.json({ error: "league_not_found" }, { status: 404 }); + } + + return NextResponse.json({ + id: updated.id, + recruitingOpen: updated.recruitingOpen, + openSeries: updated.recruitingSeries.map((entry) => entry.series), + }); + } catch (error) { + console.error("[recruiting.patch]", error); + return NextResponse.json( + { error: "internal_server_error" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/leagues/[leagueId]/schedules/[scheduleId]/details/route.ts b/src/app/api/leagues/[leagueId]/schedules/[scheduleId]/details/route.ts new file mode 100644 index 0000000..cc5ec2d --- /dev/null +++ b/src/app/api/leagues/[leagueId]/schedules/[scheduleId]/details/route.ts @@ -0,0 +1,153 @@ +import { NextRequest, NextResponse } from "next/server"; +import { Prisma } from "@prisma/client"; +import { prisma } from "@/lib/prisma"; +import { getIracingCustIdFromJwt } from "@/lib/auth/iracing"; + +/** + * PATCH /api/leagues/[leagueId]/schedules/[scheduleId]/details + * Update schedule details including weather, room open time, and green flag time + * + * Request body: + * { + * weather?: { type?: "Set" | "Realistic", temp?: number, humidity?: number, ... }, + * roomOpenTime?: string (ISO datetime) | null, + * greenFlagTime?: string (ISO datetime) | null + * } + */ +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ leagueId: string; scheduleId: string }> }, +) { + try { + const { leagueId, scheduleId } = await params; + const accessToken = request.cookies.get("irh_access_token")?.value; + + if (!accessToken) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + + try { + const iracingCustId = getIracingCustIdFromJwt(accessToken); + + // Check if user is league admin + const user = await prisma.user.findUnique({ + where: { iracingCustId }, + select: { + id: true, + leagueMemberships: { + where: { leagueId }, + select: { admin: true, owner: true }, + take: 1, + }, + }, + }); + + if (!user || !user.leagueMemberships[0]) { + return NextResponse.json( + { error: "not_league_member" }, + { status: 403 }, + ); + } + + const membership = user.leagueMemberships[0]; + if (!membership.admin && !membership.owner) { + return NextResponse.json( + { error: "insufficient_permissions" }, + { status: 403 }, + ); + } + + // Get and verify schedule belongs to league + const schedule = await prisma.schedule.findUnique({ + where: { id: scheduleId }, + select: { + id: true, + seriesId: true, + series: { select: { leagueId: true } }, + }, + }); + + if (!schedule || schedule.series.leagueId !== leagueId) { + return NextResponse.json( + { error: "schedule_not_found" }, + { status: 404 }, + ); + } + + // Parse request body + const body = (await request.json()) as { + weather?: Record | null; + roomOpenTime?: string | null; + greenFlagTime?: string | null; + }; + + // Validate datetime fields if provided + if (body.roomOpenTime !== undefined && body.roomOpenTime !== null) { + try { + new Date(body.roomOpenTime); + } catch { + return NextResponse.json( + { error: "invalid_room_open_time" }, + { status: 400 }, + ); + } + } + + if (body.greenFlagTime !== undefined && body.greenFlagTime !== null) { + try { + new Date(body.greenFlagTime); + } catch { + return NextResponse.json( + { error: "invalid_green_flag_time" }, + { status: 400 }, + ); + } + } + + // Update schedule + const updateData: Prisma.ScheduleUpdateInput = {}; + + if (body.weather !== undefined) { + updateData.weather = body.weather + ? (body.weather as Prisma.InputJsonValue) + : Prisma.JsonNull; + } + if (body.roomOpenTime !== undefined) { + updateData.roomOpenTime = body.roomOpenTime + ? new Date(body.roomOpenTime) + : null; + } + if (body.greenFlagTime !== undefined) { + updateData.greenFlagTime = body.greenFlagTime + ? new Date(body.greenFlagTime) + : null; + } + + const updated = await prisma.schedule.update({ + where: { id: scheduleId }, + data: updateData, + select: { + id: true, + eventDate: true, + raceName: true, + weather: true, + roomOpenTime: true, + greenFlagTime: true, + }, + }); + + return NextResponse.json(updated); + } catch (err) { + if (err instanceof Error && err.message.includes("JWT")) { + return NextResponse.json({ error: "invalid_token" }, { status: 401 }); + } + throw err; + } + } catch (error) { + console.error("[schedule details route]", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "failed_to_update" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/leagues/[leagueId]/schedules/[scheduleId]/registration/route.test.ts b/src/app/api/leagues/[leagueId]/schedules/[scheduleId]/registration/route.test.ts index 050c4c3..15eea58 100644 --- a/src/app/api/leagues/[leagueId]/schedules/[scheduleId]/registration/route.test.ts +++ b/src/app/api/leagues/[leagueId]/schedules/[scheduleId]/registration/route.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Prisma } from "@prisma/client"; import type { NextRequest } from "next/server"; const mocks = vi.hoisted(() => ({ @@ -15,9 +16,17 @@ const mocks = vi.hoisted(() => ({ schedule: { findUnique: vi.fn(), }, + league: { + findUnique: vi.fn(), + }, eventRegistration: { findMany: vi.fn(), + count: vi.fn(), + }, + virtualMoneyEvent: { + aggregate: vi.fn(), }, + $transaction: vi.fn(), }, getIracingCustIdFromJwt: vi.fn(), })); @@ -30,7 +39,7 @@ vi.mock("@/lib/auth/iracing", () => ({ getIracingCustIdFromJwt: mocks.getIracingCustIdFromJwt, })); -import { GET } from "./route"; +import { DELETE, GET, POST } from "./route"; function buildRequest(accessToken = "token"): NextRequest { return { @@ -44,8 +53,24 @@ function buildRequest(accessToken = "token"): NextRequest { } as unknown as NextRequest; } -function mockBaseContext(args?: { admin?: boolean }) { +const params = Promise.resolve({ + leagueId: "league-1", + scheduleId: "schedule-1", +}); + +function mockBaseContext(args?: { + admin?: boolean; + hasResults?: boolean; + registrationEnabled?: boolean; + eventDate?: string; + virtualEntryFee?: number; +}) { const admin = args?.admin ?? false; + const hasResults = args?.hasResults ?? false; + const registrationEnabled = args?.registrationEnabled ?? true; + const eventDate = + args?.eventDate ?? new Date(Date.now() + 1000 * 60 * 60).toISOString(); + const virtualEntryFee = args?.virtualEntryFee ?? 0; mocks.getIracingCustIdFromJwt.mockReturnValue(9001); mocks.prisma.user.findUnique.mockResolvedValue({ @@ -60,16 +85,16 @@ function mockBaseContext(args?: { admin?: boolean }) { id: "member-1", custId: 9001, displayName: "Driver One", - earnedVirtual: 0, + earnedVirtual: 10, }); mocks.prisma.schedule.findUnique.mockResolvedValue({ id: "schedule-1", raceName: "Round 1", - eventDate: new Date("2099-01-01T00:00:00.000Z"), - registrationEnabled: true, - virtualEntryFee: 0, + eventDate: new Date(eventDate), + registrationEnabled, + virtualEntryFee, importedSession: { - hasResults: false, + hasResults, }, series: { leagueId: "league-1", @@ -77,126 +102,324 @@ function mockBaseContext(args?: { admin?: boolean }) { }); } -describe("GET /api/leagues/[leagueId]/schedules/[scheduleId]/registration", () => { +function p2002Error() { + const error = Object.create( + Prisma.PrismaClientKnownRequestError.prototype, + ) as Prisma.PrismaClientKnownRequestError; + Object.assign(error, { code: "P2002" }); + return error; +} + +describe("registration route", () => { beforeEach(() => { vi.clearAllMocks(); }); - it("returns 401 when token is missing", async () => { - const response = await GET(buildRequest(""), { - params: Promise.resolve({ - leagueId: "league-1", - scheduleId: "schedule-1", - }), + describe("GET", () => { + it("returns 401 when token is missing", async () => { + const response = await GET(buildRequest(""), { params }); + expect(response.status).toBe(401); + await expect(response.json()).resolves.toEqual({ error: "unauthorized" }); }); - if (!response) { - throw new Error("Expected a response"); - } + it("returns 403 when requester is not a league member", async () => { + mocks.getIracingCustIdFromJwt.mockReturnValue(9001); + mocks.prisma.user.findUnique.mockResolvedValue({ + id: "user-1", + iracingCustId: 9001, + }); + mocks.prisma.leagueMembership.findUnique.mockResolvedValue(null); - expect(response.status).toBe(401); - await expect(response.json()).resolves.toEqual({ error: "unauthorized" }); - }); + const response = await GET(buildRequest(), { params }); - it("returns 403 when requester is not a league member", async () => { - mocks.getIracingCustIdFromJwt.mockReturnValue(9001); - mocks.prisma.user.findUnique.mockResolvedValue({ - id: "user-1", - iracingCustId: 9001, + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ error: "not_a_member" }); }); - mocks.prisma.leagueMembership.findUnique.mockResolvedValue(null); - const response = await GET(buildRequest(), { - params: Promise.resolve({ - leagueId: "league-1", - scheduleId: "schedule-1", - }), + it("hides registration roster for non-admin members", async () => { + mockBaseContext({ admin: false }); + mocks.prisma.eventRegistration.findMany.mockResolvedValue([ + { + id: "reg-1", + memberId: "member-1", + createdAt: new Date("2099-01-01T00:00:00.000Z"), + member: { + id: "member-1", + custId: 9001, + displayName: "Driver One", + carNumber: null, + nickName: null, + }, + }, + ]); + + const response = await GET(buildRequest(), { params }); + const payload = (await response.json()) as { + isRegistered: boolean; + registrationCount: number; + registrations?: unknown[]; + }; + + expect(response.status).toBe(200); + expect(payload.isRegistered).toBe(true); + expect(payload.registrationCount).toBe(1); + expect(payload.registrations).toBeUndefined(); }); - if (!response) { - throw new Error("Expected a response"); - } + it("includes registration roster for admins", async () => { + mockBaseContext({ admin: true }); + mocks.prisma.eventRegistration.findMany.mockResolvedValue([ + { + id: "reg-1", + memberId: "member-1", + createdAt: new Date("2099-01-01T00:00:00.000Z"), + member: { + id: "member-1", + custId: 9001, + displayName: "Driver One", + carNumber: null, + nickName: null, + }, + }, + ]); + + const response = await GET(buildRequest(), { params }); + const payload = (await response.json()) as { + registrations?: Array<{ id: string }>; + }; - expect(response.status).toBe(403); - await expect(response.json()).resolves.toEqual({ error: "not_a_member" }); + expect(response.status).toBe(200); + expect(payload.registrations).toHaveLength(1); + expect(payload.registrations?.[0]?.id).toBe("reg-1"); + }); }); - it("hides member registration roster for non-admin members", async () => { - mockBaseContext({ admin: false }); - mocks.prisma.eventRegistration.findMany.mockResolvedValue([ - { - id: "reg-1", - memberId: "member-1", - createdAt: new Date("2099-01-01T00:00:00.000Z"), - member: { - id: "member-1", - custId: 9001, - displayName: "Driver One", - carNumber: null, - nickName: null, - }, - }, - ]); - - const response = await GET(buildRequest(), { - params: Promise.resolve({ - leagueId: "league-1", - scheduleId: "schedule-1", - }), + describe("POST", () => { + it("returns 409 when registration is disabled", async () => { + mockBaseContext({ registrationEnabled: false }); + + const response = await POST(buildRequest(), { params }); + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ + error: "registration_disabled", + }); + }); + + it("returns 409 when event has started", async () => { + mockBaseContext({ + eventDate: new Date(Date.now() - 1000 * 60).toISOString(), + }); + + const response = await POST(buildRequest(), { params }); + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ + error: "event_passed", + }); + }); + + it("returns 404 when league settings are missing", async () => { + mockBaseContext(); + mocks.prisma.league.findUnique.mockResolvedValue(null); + + const response = await POST(buildRequest(), { params }); + expect(response.status).toBe(404); + await expect(response.json()).resolves.toEqual({ + error: "league_not_found", + }); }); - if (!response) { - throw new Error("Expected a response"); - } + it("registers successfully without virtual mode charges", async () => { + mockBaseContext({ virtualEntryFee: 50 }); + mocks.prisma.league.findUnique.mockResolvedValue({ + virtualModeEnabled: false, + virtualStartingMoney: 100, + }); + mocks.prisma.$transaction.mockImplementation(async (fn: any) => + fn({ + eventRegistration: { create: vi.fn() }, + virtualMoneyEvent: { aggregate: vi.fn(), create: vi.fn() }, + }), + ); + mocks.prisma.eventRegistration.count.mockResolvedValue(3); + mocks.prisma.virtualMoneyEvent.aggregate.mockResolvedValue({ + _sum: { amount: 5 }, + }); + + const response = await POST(buildRequest(), { params }); + const payload = (await response.json()) as { + success: boolean; + isRegistered: boolean; + registrationCount: number; + virtualBalance: number; + }; - expect(response.status).toBe(200); + expect(response.status).toBe(200); + expect(payload.success).toBe(true); + expect(payload.isRegistered).toBe(true); + expect(payload.registrationCount).toBe(3); + expect(payload.virtualBalance).toBe(115); + }); - const payload = (await response.json()) as { - isRegistered: boolean; - registrationCount: number; - registrations?: unknown[]; - }; + it("returns 409 for insufficient virtual funds", async () => { + mockBaseContext({ virtualEntryFee: 200 }); + mocks.prisma.league.findUnique.mockResolvedValue({ + virtualModeEnabled: true, + virtualStartingMoney: 100, + }); - expect(payload.isRegistered).toBe(true); - expect(payload.registrationCount).toBe(1); - expect(payload.registrations).toBeUndefined(); + const txEventCreate = vi.fn(); + const txLedgerAggregate = vi + .fn() + .mockResolvedValue({ _sum: { amount: 0 } }); + mocks.prisma.$transaction.mockImplementation(async (fn: any) => + fn({ + eventRegistration: { create: vi.fn() }, + virtualMoneyEvent: { + aggregate: txLedgerAggregate, + create: txEventCreate, + }, + }), + ); + + const response = await POST(buildRequest(), { params }); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ + error: "insufficient_virtual_funds", + }); + expect(txEventCreate).not.toHaveBeenCalled(); + }); + + it("returns success when duplicate registration error occurs", async () => { + mockBaseContext({ virtualEntryFee: 0 }); + mocks.prisma.league.findUnique.mockResolvedValue({ + virtualModeEnabled: false, + virtualStartingMoney: 100, + }); + mocks.prisma.$transaction.mockRejectedValue(p2002Error()); + mocks.prisma.eventRegistration.count.mockResolvedValue(2); + mocks.prisma.virtualMoneyEvent.aggregate.mockResolvedValue({ + _sum: { amount: 20 }, + }); + + const response = await POST(buildRequest(), { params }); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + success: true, + isRegistered: true, + registrationCount: 2, + virtualBalance: 130, + }); + }); + + it("returns 500 for unknown transaction failures", async () => { + mockBaseContext(); + mocks.prisma.league.findUnique.mockResolvedValue({ + virtualModeEnabled: false, + virtualStartingMoney: 100, + }); + mocks.prisma.$transaction.mockRejectedValue(new Error("boom")); + + const response = await POST(buildRequest(), { params }); + expect(response.status).toBe(500); + await expect(response.json()).resolves.toEqual({ + error: "internal_server_error", + }); + }); }); - it("includes member registration roster for admins", async () => { - mockBaseContext({ admin: true }); - mocks.prisma.eventRegistration.findMany.mockResolvedValue([ - { - id: "reg-1", - memberId: "member-1", - createdAt: new Date("2099-01-01T00:00:00.000Z"), - member: { - id: "member-1", - custId: 9001, - displayName: "Driver One", - carNumber: null, - nickName: null, - }, - }, - ]); - - const response = await GET(buildRequest(), { - params: Promise.resolve({ - leagueId: "league-1", - scheduleId: "schedule-1", - }), + describe("DELETE", () => { + it("returns 409 when results are posted", async () => { + mockBaseContext({ hasResults: true }); + + const response = await DELETE(buildRequest(), { params }); + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ + error: "registration_closed_results_posted", + }); }); - if (!response) { - throw new Error("Expected a response"); - } + it("returns 404 when league settings are missing", async () => { + mockBaseContext(); + mocks.prisma.league.findUnique.mockResolvedValue(null); - expect(response.status).toBe(200); + const response = await DELETE(buildRequest(), { params }); + expect(response.status).toBe(404); + await expect(response.json()).resolves.toEqual({ + error: "league_not_found", + }); + }); - const payload = (await response.json()) as { - registrations?: Array<{ id: string }>; - }; + it("unregisters successfully and refunds outstanding debit", async () => { + mockBaseContext(); + mocks.prisma.league.findUnique.mockResolvedValue({ + virtualModeEnabled: true, + virtualStartingMoney: 100, + }); - expect(payload.registrations).toHaveLength(1); - expect(payload.registrations?.[0]?.id).toBe("reg-1"); + const txDeleteMany = vi.fn().mockResolvedValue({ count: 1 }); + const txAggregate = vi + .fn() + .mockResolvedValueOnce({ _sum: { amount: -25 } }) + .mockResolvedValueOnce({ _sum: { amount: -10 } }); + const txCreate = vi.fn(); + + mocks.prisma.$transaction.mockImplementation(async (fn: any) => + fn({ + eventRegistration: { deleteMany: txDeleteMany }, + virtualMoneyEvent: { + aggregate: txAggregate, + create: txCreate, + }, + }), + ); + + mocks.prisma.eventRegistration.count.mockResolvedValue(1); + mocks.prisma.virtualMoneyEvent.aggregate.mockResolvedValue({ + _sum: { amount: 0 }, + }); + + const response = await DELETE(buildRequest(), { params }); + const payload = (await response.json()) as { + success: boolean; + isRegistered: boolean; + }; + + expect(response.status).toBe(200); + expect(payload.success).toBe(true); + expect(payload.isRegistered).toBe(false); + expect(txCreate).toHaveBeenCalled(); + }); + + it("unregisters with no refund when nothing was debited", async () => { + mockBaseContext(); + mocks.prisma.league.findUnique.mockResolvedValue({ + virtualModeEnabled: true, + virtualStartingMoney: 100, + }); + + const txCreate = vi.fn(); + mocks.prisma.$transaction.mockImplementation(async (fn: any) => + fn({ + eventRegistration: { + deleteMany: vi.fn().mockResolvedValue({ count: 1 }), + }, + virtualMoneyEvent: { + aggregate: vi.fn().mockResolvedValue({ _sum: { amount: 0 } }), + create: txCreate, + }, + }), + ); + + mocks.prisma.eventRegistration.count.mockResolvedValue(0); + mocks.prisma.virtualMoneyEvent.aggregate.mockResolvedValue({ + _sum: { amount: 0 }, + }); + + const response = await DELETE(buildRequest(), { params }); + + expect(response.status).toBe(200); + expect(txCreate).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/app/api/leagues/[leagueId]/series/[seriesId]/seasons/[seasonId]/schedules/[scheduleId]/route.ts b/src/app/api/leagues/[leagueId]/series/[seriesId]/seasons/[seasonId]/schedules/[scheduleId]/route.ts index 5dbed48..a0a0a33 100644 --- a/src/app/api/leagues/[leagueId]/series/[seriesId]/seasons/[seasonId]/schedules/[scheduleId]/route.ts +++ b/src/app/api/leagues/[leagueId]/series/[seriesId]/seasons/[seasonId]/schedules/[scheduleId]/route.ts @@ -5,12 +5,15 @@ import { Prisma } from "@prisma/client"; interface ScheduleUpdateRequest { eventDate?: string; + roomOpenAt?: string; raceName?: string; isOffWeek?: boolean; pointsCount?: boolean; canDrop?: boolean; registrationEnabled?: boolean; trackName?: string; + trackConfigName?: string; + trackCategory?: string; trackId?: number; raceLength?: string; virtualPurse?: number; @@ -141,9 +144,39 @@ export async function PATCH( } if (data.stages !== undefined) updateData.stages = normalizeStages(data.stages) as Prisma.JsonValue; - if (data.weather !== undefined) - updateData.weather = data.weather as Prisma.JsonValue; + if (data.weather !== undefined || data.roomOpenAt !== undefined) { + const baseWeather = + schedule.weather && typeof schedule.weather === "object" + ? (schedule.weather as Record) + : {}; + updateData.weather = { + ...baseWeather, + ...(data.weather ?? {}), + roomOpenAt: + data.roomOpenAt ?? + (baseWeather.roomOpenAt as string | undefined) ?? + null, + raceStartAt: + data.eventDate ?? + (baseWeather.raceStartAt as string | undefined) ?? + schedule.eventDate.toISOString(), + track: data.trackId + ? { + id: data.trackId, + name: data.trackName ?? null, + configName: data.trackConfigName ?? null, + category: data.trackCategory ?? null, + } + : ((baseWeather.track as Record | undefined) ?? + null), + } as Prisma.JsonValue; + } if (data.raceOrder !== undefined) updateData.raceOrder = data.raceOrder; + if (data.trackName !== undefined) { + updateData.trackName = data.trackName + ? [data.trackName, data.trackConfigName].filter(Boolean).join(" Β· ") + : null; + } const updated = await prisma.schedule.update({ where: { id: scheduleId }, 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 cd8073e..4d63a1e 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 @@ -5,12 +5,15 @@ import { Prisma } from "@prisma/client"; interface ScheduleRequest { eventDate: string; + roomOpenAt?: string; raceName: string; isOffWeek: boolean; pointsCount: boolean; canDrop: boolean; registrationEnabled?: boolean; trackName?: string; + trackConfigName?: string; + trackCategory?: string; trackId?: number; raceLength?: string; virtualPurse?: number; @@ -216,6 +219,32 @@ export async function POST( // Create the schedule const stages = normalizeStages(data.stages); + const weatherPayload = { + ...(data.weather ?? {}), + roomOpenAt: + data.roomOpenAt ?? + (data.weather as { roomOpenAt?: string } | undefined)?.roomOpenAt ?? + null, + raceStartAt: data.eventDate, + track: data.trackId + ? { + id: data.trackId, + name: data.trackName ?? null, + configName: data.trackConfigName ?? null, + category: data.trackCategory ?? null, + } + : null, + }; + + const latestSchedule = await prisma.schedule.findFirst({ + where: { seasonId }, + orderBy: { raceOrder: "desc" }, + select: { raceOrder: true }, + }); + + const trackDisplayName = data.trackName + ? [data.trackName, data.trackConfigName].filter(Boolean).join(" Β· ") + : null; const schedule = await prisma.schedule.create({ data: { @@ -227,7 +256,7 @@ export async function POST( pointsCount: data.pointsCount, canDrop: data.canDrop, registrationEnabled: data.registrationEnabled ?? true, - trackName: data.trackName, + trackName: trackDisplayName, trackId: data.trackId, raceLength: data.raceLength, virtualPurse: @@ -243,8 +272,8 @@ export async function POST( data.virtualPayoutSplit, ) as Prisma.InputJsonValue, stages: stages as Prisma.InputJsonValue, - weather: data.weather as Prisma.InputJsonValue, - raceOrder: data.raceOrder, + weather: weatherPayload as Prisma.InputJsonValue, + raceOrder: data.raceOrder ?? (latestSchedule?.raceOrder ?? 0) + 1, }, }); diff --git a/src/app/api/leagues/[leagueId]/series/[seriesId]/seasons/[seasonId]/sessions/[raceSessionId]/results/route.ts b/src/app/api/leagues/[leagueId]/series/[seriesId]/seasons/[seasonId]/sessions/[raceSessionId]/results/route.ts index 892d2da..fbbd21b 100644 --- a/src/app/api/leagues/[leagueId]/series/[seriesId]/seasons/[seasonId]/sessions/[raceSessionId]/results/route.ts +++ b/src/app/api/leagues/[leagueId]/series/[seriesId]/seasons/[seasonId]/sessions/[raceSessionId]/results/route.ts @@ -220,6 +220,13 @@ export async function POST( return NextResponse.json({ error: "invalid_cust_id" }, { status: 400 }); } + if (!data.displayName?.trim()) { + return NextResponse.json( + { error: "display_name_required" }, + { status: 400 }, + ); + } + const raceSession = await prisma.raceSession.findFirst({ where: { id: raceSessionId, leagueId }, include: { @@ -265,6 +272,13 @@ export async function POST( select: { id: true }, }); + if (data.provisional && !member) { + return NextResponse.json( + { error: "provisional_member_must_exist_in_league" }, + { status: 400 }, + ); + } + const result = await prisma.raceSessionResult.upsert({ where: { raceSessionId_custId: { diff --git a/src/app/api/leagues/[leagueId]/series/[seriesId]/seasons/[seasonId]/sessions/route.ts b/src/app/api/leagues/[leagueId]/series/[seriesId]/seasons/[seasonId]/sessions/route.ts index 45aea48..5f869be 100644 --- a/src/app/api/leagues/[leagueId]/series/[seriesId]/seasons/[seasonId]/sessions/route.ts +++ b/src/app/api/leagues/[leagueId]/series/[seriesId]/seasons/[seasonId]/sessions/route.ts @@ -49,54 +49,67 @@ export async function GET( return NextResponse.json({ error: "forbidden" }, { status: auth.status }); } - const sessions = await prisma.raceSession.findMany({ - where: { leagueId, seriesId, seasonId }, - orderBy: { launchAt: "asc" }, - include: { - schedule: { - select: { - id: true, - raceName: true, - eventDate: true, - raceOrder: true, - pointsCount: true, - canDrop: true, - stages: true, + const [sessions, league] = await Promise.all([ + prisma.raceSession.findMany({ + where: { leagueId, seriesId, seasonId }, + orderBy: { launchAt: "asc" }, + include: { + schedule: { + select: { + id: true, + raceName: true, + eventDate: true, + raceOrder: true, + pointsCount: true, + canDrop: true, + stages: true, + virtualPurse: true, + virtualPayoutSplit: true, + }, }, - }, - pointsConfig: { - select: { - id: true, - positionPoints: true, - bonusPoints: true, - allowProvisionals: true, + pointsConfig: { + select: { + id: true, + positionPoints: true, + bonusPoints: true, + allowProvisionals: true, + }, }, - }, - results: { - orderBy: { finishPosition: "asc" }, - select: { - id: true, - custId: true, - displayName: true, - finishPosition: true, - startPosition: true, - lapsCompleted: true, - incidents: true, - provisional: true, - pointsBase: true, - stageFinishes: true, - pointsAdjustment: true, - finalPoints: true, - notes: true, + results: { + orderBy: { finishPosition: "asc" }, + select: { + id: true, + custId: true, + displayName: true, + finishPosition: true, + startPosition: true, + lapsCompleted: true, + incidents: true, + provisional: true, + pointsBase: true, + stageFinishes: true, + pointsAdjustment: true, + bonusPoints: true, + penaltyPoints: true, + finalPoints: true, + notes: true, + }, + }, + _count: { + select: { results: true }, }, }, - _count: { - select: { results: true }, - }, - }, + }), + prisma.league.findUnique({ + where: { id: leagueId }, + select: { virtualModeEnabled: true }, + }), + ]); + + return NextResponse.json({ + sessions, + virtualModeEnabled: league?.virtualModeEnabled ?? false, }); - - return NextResponse.json(sessions); } /** diff --git a/src/app/api/leagues/[leagueId]/sessions/lookup/route.ts b/src/app/api/leagues/[leagueId]/sessions/lookup/route.ts new file mode 100644 index 0000000..d1eab1e --- /dev/null +++ b/src/app/api/leagues/[leagueId]/sessions/lookup/route.ts @@ -0,0 +1,79 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { getIracingCustIdFromJwt } from "@/lib/auth/iracing"; + +async function assertAdmin(leagueId: string, request: NextRequest) { + const accessToken = request.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 }; +} + +/** + * GET /api/leagues/[leagueId]/sessions/lookup?subsessionId=12345 + * Returns any existing RaceSession in this league that matches the given subsessionId. + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ leagueId: string }> }, +) { + const { leagueId } = await params; + + const auth = await assertAdmin(leagueId, request); + if (!auth.ok) { + return NextResponse.json({ error: "forbidden" }, { status: auth.status }); + } + + const url = new URL(request.url); + const subsessionIdStr = url.searchParams.get("subsessionId"); + const subsessionId = subsessionIdStr ? parseInt(subsessionIdStr, 10) : NaN; + + if (isNaN(subsessionId) || subsessionId <= 0) { + return NextResponse.json( + { error: "subsessionId query param required" }, + { status: 400 }, + ); + } + + const match = await prisma.raceSession.findFirst({ + where: { leagueId, subsessionId }, + select: { + id: true, + subsessionId: true, + trackName: true, + launchAt: true, + hasResults: true, + schedule: { + select: { + raceName: true, + eventDate: true, + season: { + select: { + seasonName: true, + series: { + select: { name: true }, + }, + }, + }, + }, + }, + }, + }); + + return NextResponse.json({ match: match ?? null }); +} diff --git a/src/app/api/leagues/route.ts b/src/app/api/leagues/route.ts index 99c0383..adb187e 100644 --- a/src/app/api/leagues/route.ts +++ b/src/app/api/leagues/route.ts @@ -42,6 +42,28 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: "user_not_found" }, { status: 404 }); } + const adminLeagueIds = user.leagueMemberships + .filter((membership) => membership.owner || membership.admin) + .map((membership) => membership.league.id); + + const pendingCounts = + adminLeagueIds.length > 0 + ? await prisma.leagueJoinRequest.groupBy({ + by: ["leagueId"], + where: { + leagueId: { in: adminLeagueIds }, + status: "PENDING", + }, + _count: { + _all: true, + }, + }) + : []; + + const pendingCountByLeagueId = new Map( + pendingCounts.map((entry) => [entry.leagueId, entry._count._all]), + ); + const leagues = user.leagueMemberships.map((m) => ({ id: m.league.id, iracingLeagueId: m.league.iracingLeagueId, @@ -53,6 +75,8 @@ export async function GET(request: NextRequest) { rosterCount: m.league.rosterCount, owner: m.owner, admin: m.admin, + pendingJoinRequests: + m.owner || m.admin ? (pendingCountByLeagueId.get(m.league.id) ?? 0) : 0, lastSyncedAt: m.lastSyncedAt, })); diff --git a/src/app/app/[leagueId]/admin/dashboard.tsx b/src/app/app/[leagueId]/admin/dashboard.tsx new file mode 100644 index 0000000..a90a180 --- /dev/null +++ b/src/app/app/[leagueId]/admin/dashboard.tsx @@ -0,0 +1,331 @@ +"use client"; + +import Link from "next/link"; +import Image from "next/image"; +import { useParams, useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { useAuth } from "@/components/AuthProvider"; +import { + Trophy, + Users, + Grid, + Share2, + Settings, + BarChart3, + ExternalLink, +} from "lucide-react"; + +interface LeagueDetail { + id: string; + iracingLeagueId: number | null; + routeLeagueId: string; + leagueName: string; + smallLogo: string | null; + rosterCount: number | null; + owner: boolean; + admin: boolean; +} + +interface AdminStats { + seriesCount: number; + memberCount: number; + pendingJoinRequests: number; +} + +export default function AdminDashboard() { + const { session, loading: authLoading, logout } = useAuth(); + const router = useRouter(); + const params = useParams<{ leagueId: string }>(); + + const [league, setLeague] = useState(null); + const [stats, setStats] = useState({ + seriesCount: 0, + memberCount: 0, + pendingJoinRequests: 0, + }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!authLoading && !session?.authenticated) { + router.replace("/"); + } + }, [authLoading, session, router]); + + useEffect(() => { + if (!session?.authenticated) return; + + async function load() { + setLoading(true); + setError(null); + try { + const res = await fetch("/api/leagues", { cache: "no-store" }); + const data = (await res.json()) as { + leagues?: LeagueDetail[]; + error?: string; + }; + if (!res.ok) throw new Error(data.error ?? "fetch_failed"); + + const found = + data.leagues?.find( + (l) => + l.id === params.leagueId || + l.routeLeagueId === params.leagueId || + String(l.iracingLeagueId) === params.leagueId, + ) ?? null; + + if (!found) { + setError("League not found"); + } else if (!found.owner && !found.admin) { + setError("You don't have admin access"); + } else { + setLeague(found); + + // Load stats + const [seriesRes, membersRes, joinReqRes] = await Promise.all([ + fetch(`/api/leagues/${found.id}/series`, { cache: "no-store" }), + fetch(`/api/leagues/${found.id}/members`, { cache: "no-store" }), + fetch(`/api/leagues/${found.id}/join-requests`, { + cache: "no-store", + }), + ]); + + const seriesData = seriesRes.ok ? await seriesRes.json() : []; + const membersData = membersRes.ok ? await membersRes.json() : []; + const joinReqData = joinReqRes.ok ? await joinReqRes.json() : []; + + setStats({ + seriesCount: Array.isArray(seriesData) ? seriesData.length : 0, + memberCount: Array.isArray(membersData) ? membersData.length : 0, + pendingJoinRequests: Array.isArray(joinReqData) + ? joinReqData.filter((r: any) => r.status === "PENDING").length + : 0, + }); + } + } catch (err) { + setError(err instanceof Error ? err.message : "error_loading"); + } finally { + setLoading(false); + } + } + + load(); + }, [session?.authenticated, params.leagueId]); + + if (authLoading || loading) { + return ( +
+
+
+ ); + } + + if (!session?.authenticated) return null; + + const adminSections = [ + { + title: "Series & Seasons", + description: "Manage racing series, seasons, and schedules", + icon: Trophy, + href: `/app/${league?.routeLeagueId}/admin/series`, + stat: stats.seriesCount, + statLabel: "series", + }, + { + title: "Members", + description: "View and manage league members", + icon: Users, + href: `/app/${league?.routeLeagueId}/admin/members`, + stat: stats.memberCount, + statLabel: "members", + badge: + stats.pendingJoinRequests > 0 ? stats.pendingJoinRequests : undefined, + }, + { + title: "Points Systems", + description: "Create and manage scoring systems", + icon: Grid, + href: `/app/${league?.routeLeagueId}/admin/points-system`, + }, + { + title: "Widgets", + description: "Generate embeddable league widgets", + icon: Share2, + href: `/app/${league?.routeLeagueId}/admin/widgets`, + }, + { + title: "Settings", + description: "Configure virtual money, recruiting, and more", + icon: Settings, + href: `/app/${league?.routeLeagueId}/admin/settings`, + }, + ]; + + return ( +
+
+
+ + iRaceHub + +
+ {league && ( + + ← League View + + )} + +
+
+
+ +
+ {error && !league ? ( +
+

{error}

+ + ← Back to Dashboard + +
+ ) : league ? ( + <> + {/* League Header */} +
+
+ {league.smallLogo ? ( + {league.leagueName} + ) : ( +
+ 🏁 +
+ )} +
+
+

+ {league.leagueName} +

+ + Admin + +
+

+ {league.iracingLeagueId + ? `iRacing League ID: ${league.iracingLeagueId}` + : "Not linked to iRacing"} + {league.rosterCount + ? ` Β· ${league.rosterCount} members` + : ""} +

+

+ Role:{" "} + + {league.owner ? "Owner" : "Admin"} + +

+
+
+
+ + {/* Admin Sections Grid */} +
+ {adminSections.map((section) => { + const Icon = section.icon; + return ( + +
+
+ +
+ {section.badge && ( + + {section.badge} + + )} +
+ +

+ {section.title} +

+

+ {section.description} +

+ + {section.stat !== undefined && ( +
+

+ {section.statLabel} +

+

+ {section.stat} +

+
+ )} + +
+ Go to {section.title} +
+ + ); + })} +
+ + {/* Quick Links */} +
+

Quick Actions

+
+ + Pending Join Requests + {stats.pendingJoinRequests > 0 && ( + + {stats.pendingJoinRequests} + + )} + + + Create Points System + + + View Documentation + +
+
+ + ) : null} +
+
+ ); +} diff --git a/src/app/app/[leagueId]/admin/join-requests/page.tsx b/src/app/app/[leagueId]/admin/join-requests/page.tsx new file mode 100644 index 0000000..93fc221 --- /dev/null +++ b/src/app/app/[leagueId]/admin/join-requests/page.tsx @@ -0,0 +1,429 @@ +"use client"; + +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; +import { useAuth } from "@/components/AuthProvider"; + +interface LeagueSummary { + id: string; + iracingLeagueId: number | null; + routeLeagueId: string; + leagueName: string; + owner: boolean; + admin: boolean; +} + +interface JoinRequestEntry { + id: string; + requesterCustId: number; + fullName: string; + state: string; + country: string; + whyJoin: string; + status: "PENDING" | "APPROVED" | "DECLINED"; + createdAt: string; + updatedAt: string; + reviewedAt: string | null; + requestedSeries: Array<{ id: string; name: string }>; + isLeagueMember: boolean; + reviewedBy: { + id: string; + displayName: string | null; + iracingCustId: number; + } | null; +} + +interface JoinRequestsPayload { + league: { + id: string; + leagueName: string; + iracingLeagueId: number | null; + url: string | null; + }; + requests: JoinRequestEntry[]; +} + +async function readJsonSafely(response: Response): Promise { + const text = await response.text(); + if (!text) return null; + + try { + return JSON.parse(text) as T; + } catch { + return null; + } +} + +async function getApiErrorMessage(response: Response, fallback: string) { + const payload = await readJsonSafely<{ error?: string; message?: string }>( + response, + ); + + if (payload?.message) return payload.message; + if (payload?.error) return payload.error; + + return fallback; +} + +export default function JoinRequestsPage() { + const { session, loading: authLoading, logout } = useAuth(); + const router = useRouter(); + const params = useParams<{ leagueId: string }>(); + + const [league, setLeague] = useState(null); + const [requests, setRequests] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [actioningRequestId, setActioningRequestId] = useState( + null, + ); + const [actionNotice, setActionNotice] = useState(null); + const [manualIracingUrl, setManualIracingUrl] = useState(null); + + useEffect(() => { + if (!authLoading && !session?.authenticated) { + router.replace("/"); + } + }, [authLoading, session, router]); + + useEffect(() => { + if (!session?.authenticated) return; + + async function load() { + setLoading(true); + setError(null); + try { + const leaguesResponse = await fetch("/api/leagues", { + cache: "no-store", + }); + + const leaguesPayload = await readJsonSafely<{ + leagues?: LeagueSummary[]; + error?: string; + }>(leaguesResponse); + + if (!leaguesResponse.ok) { + throw new Error(leaguesPayload?.error ?? "failed_to_load_leagues"); + } + + const foundLeague = + leaguesPayload?.leagues?.find( + (leagueItem) => + leagueItem.id === params.leagueId || + leagueItem.routeLeagueId === params.leagueId || + String(leagueItem.iracingLeagueId) === params.leagueId, + ) ?? null; + + if (!foundLeague) { + throw new Error("league_not_found"); + } + + if (!foundLeague.owner && !foundLeague.admin) { + throw new Error("forbidden_not_owner_or_admin"); + } + + setLeague(foundLeague); + + const joinRequestsResponse = await fetch( + `/api/leagues/${foundLeague.id}/join-requests`, + { + cache: "no-store", + }, + ); + + const joinRequestsPayload = await readJsonSafely< + JoinRequestsPayload & { error?: string } + >(joinRequestsResponse); + + if (!joinRequestsResponse.ok || !joinRequestsPayload) { + throw new Error( + joinRequestsPayload?.error ?? "failed_to_load_join_requests", + ); + } + + setRequests(joinRequestsPayload.requests); + } catch (err) { + setError(err instanceof Error ? err.message : "unknown_error"); + } finally { + setLoading(false); + } + } + + void load(); + }, [session?.authenticated, params.leagueId]); + + const pendingCount = useMemo( + () => requests.filter((request) => request.status === "PENDING").length, + [requests], + ); + + async function reviewRequest( + requestId: string, + action: "approve" | "decline", + ) { + if (!league) return; + + setActioningRequestId(requestId); + setActionNotice(null); + setManualIracingUrl(null); + + try { + const response = await fetch( + `/api/leagues/${league.id}/join-requests/${requestId}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action }), + }, + ); + + if (!response.ok) { + const message = await getApiErrorMessage(response, "failed_to_review"); + throw new Error(message); + } + + const payload = + (await readJsonSafely<{ + request?: { + id: string; + status: "PENDING" | "APPROVED" | "DECLINED"; + reviewedAt: string | null; + }; + needsManualIracingAdd?: boolean; + iracingLeagueAdminUrl?: string | null; + }>(response)) ?? {}; + + setRequests((prev) => + prev.map((entry) => + entry.id === requestId + ? { + ...entry, + status: payload.request?.status ?? entry.status, + reviewedAt: payload.request?.reviewedAt ?? entry.reviewedAt, + } + : entry, + ), + ); + + if (action === "approve") { + if (payload.needsManualIracingAdd && payload.iracingLeagueAdminUrl) { + setManualIracingUrl(payload.iracingLeagueAdminUrl); + setActionNotice( + "Request approved. Driver is not on your synced roster yetβ€”add them in iRacing, then sync members.", + ); + } else { + setActionNotice("Request approved."); + } + } else { + setActionNotice("Request declined."); + } + } catch (err) { + setActionNotice(err instanceof Error ? err.message : "review_failed"); + } finally { + setActioningRequestId(null); + } + } + + if (authLoading || loading) { + return ( +
+
+
+ ); + } + + if (!session?.authenticated) return null; + + return ( +
+
+
+ + iRaceHub + +
+ {league && ( + <> + + ← Admin Panel + + + League View + + + )} + +
+
+
+ +
+ {error ? ( +
+

{error}

+ + ← Back to Dashboard + +
+ ) : league ? ( + <> +
+

+ Admin +

+

+ Join Requests +

+

+ {league.leagueName} Β· {pendingCount} pending +

+
+ + {actionNotice && ( +
+

{actionNotice}

+ {manualIracingUrl && ( + + Open iRacing league page β†’ + + )} +
+ )} + + {requests.length === 0 ? ( +
+

No join requests yet.

+
+ ) : ( +
+ {requests.map((request) => ( +
+
+
+

+ {request.fullName} +

+

+ iRacing ID: {request.requesterCustId} Β·{" "} + {request.state}, {request.country} +

+
+ + {request.status} + +
+ +
+
+

+ Why they want to join +

+

+ {request.whyJoin} +

+
+ +
+

+ Requested Series +

+
+ {request.requestedSeries.map((series) => ( + + {series.name} + + ))} +
+
+
+ +
+ + Submitted {new Date(request.createdAt).toLocaleString()} + + {request.isLeagueMember && ( + + Already synced as member + + )} + + View driver profile β†’ + +
+ + {request.status === "PENDING" && ( +
+ + +
+ )} +
+ ))} +
+ )} + + ) : null} +
+
+ ); +} diff --git a/src/app/app/[leagueId]/admin/members/page.tsx b/src/app/app/[leagueId]/admin/members/page.tsx new file mode 100644 index 0000000..76c2de8 --- /dev/null +++ b/src/app/app/[leagueId]/admin/members/page.tsx @@ -0,0 +1,387 @@ +"use client"; + +import { useParams, useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { useAuth } from "@/components/AuthProvider"; +import Link from "next/link"; + +interface Helmet { + pattern: number; + color1: string; + color2: string; + color3: string; + face_type: number; + helmet_type: number; +} + +interface Member { + id: string; + custId: number; + displayName: string; + owner: boolean; + admin: boolean; + leagueMailOptOut: boolean | null; + leaguePmOptOut: boolean | null; + leagueMemberSince: string; + carNumber: string | null; + nickName: string | null; + helmet: Helmet; + lastSyncedAt: string; + createdAt: string; + updatedAt: string; +} + +interface LeagueDetail { + id: string; + iracingLeagueId: number | null; + routeLeagueId: string; + leagueName: string; + smallLogo: string | null; + rosterCount: number | null; + owner: boolean; + admin: boolean; +} + +export default function AdminMembersPage() { + const { session, loading: authLoading, logout } = useAuth(); + const router = useRouter(); + const params = useParams<{ leagueId: string }>(); + + const [league, setLeague] = useState(null); + const [members, setMembers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [memberPage, setMemberPage] = useState(1); + const [membersPerPage, setMembersPerPage] = useState(20); + const [memberSearch, setMemberSearch] = useState(""); + const [syncingMembers, setSyncingMembers] = useState(false); + + useEffect(() => { + if (!authLoading && !session?.authenticated) { + router.replace("/"); + } + }, [authLoading, session, router]); + + useEffect(() => { + if (!session?.authenticated) return; + + async function load() { + setLoading(true); + setError(null); + try { + const res = await fetch("/api/leagues", { cache: "no-store" }); + const data = (await res.json()) as { + leagues?: LeagueDetail[]; + error?: string; + }; + if (!res.ok) throw new Error(data.error ?? "fetch_failed"); + + const found = + data.leagues?.find( + (l) => + l.id === params.leagueId || + l.routeLeagueId === params.leagueId || + String(l.iracingLeagueId) === params.leagueId, + ) ?? null; + + if (!found) { + setError("League not found or you are not a member."); + } else if (!found.owner && !found.admin) { + setError("You do not have admin access to this league."); + } else { + setLeague(found); + + // Fetch members + const membersRes = await fetch(`/api/leagues/${found.id}/members`, { + cache: "no-store", + }); + + if (membersRes.ok) { + setMembers((await membersRes.json()) as Member[]); + } + } + } catch (err) { + setError(err instanceof Error ? err.message : "unknown_error"); + } finally { + setLoading(false); + } + } + + load(); + }, [session?.authenticated, params.leagueId]); + + const handleSyncMembers = async () => { + if (!league) return; + + setSyncingMembers(true); + try { + const response = await fetch(`/api/leagues/${league.id}/members/sync`, { + method: "POST", + }); + + if (!response.ok) { + throw new Error("Failed to sync members"); + } + + const updated = (await response.json()) as Member[]; + setMembers(updated); + } catch (err) { + alert(err instanceof Error ? err.message : "failed_to_sync_members"); + } finally { + setSyncingMembers(false); + } + }; + + if (authLoading || loading) { + return ( +
+
+
+ ); + } + + if (!session?.authenticated) return null; + + if (error && !league) { + return ( +
+
+

{error}

+ + ← Back to Dashboard + +
+
+ ); + } + + const normalizedMemberSearch = memberSearch.trim().toLowerCase(); + const filteredMembers = members.filter((member) => { + if (!normalizedMemberSearch) return true; + + const searchable = [ + member.displayName, + member.nickName ?? "", + member.carNumber ?? "", + String(member.custId), + ] + .join(" ") + .toLowerCase(); + + return searchable.includes(normalizedMemberSearch); + }); + + const totalMemberPages = Math.max( + 1, + Math.ceil(filteredMembers.length / membersPerPage), + ); + const currentMemberPage = Math.min(memberPage, totalMemberPages); + const memberStartIndex = (currentMemberPage - 1) * membersPerPage; + const paginatedMembers = filteredMembers.slice( + memberStartIndex, + memberStartIndex + membersPerPage, + ); + + return ( +
+
+
+
+

Admin Panel / Members

+

{league?.leagueName}

+
+
+ {league && ( + + ← League View + + )} + +
+
+
+ +
+ {league && ( +
+
+
+

League Members

+

+ {members.length} total members synced from iRacing +

+
+ +
+ + {/* Search and Filtering */} +
+ { + setMemberSearch(e.target.value); + setMemberPage(1); + }} + placeholder="Search by name, nickname, car #, or ID..." + className="flex-1 rounded-lg bg-zinc-900 border border-zinc-700 text-zinc-200 px-4 py-2.5 text-sm focus:outline-none focus:border-red-500" + /> + +
+ + {members.length === 0 ? ( +
+

+ No members synced yet. Sync members from iRacing to get + started. +

+ +
+ ) : ( + <> + {/* Members Grid */} +
+ {paginatedMembers.map((member) => ( +
+
+ {/* Helmet Visual */} +
+
+ {member.carNumber && member.carNumber.length <= 2 + ? member.carNumber + : "πŸ‘€"} +
+
+ + {/* Member Info */} +
+

+ {member.displayName} +

+ {member.nickName && ( +

+ {member.nickName} +

+ )} +
+ {member.owner && ( + + Owner + + )} + {member.admin && ( + + Admin + + )} + {member.carNumber && ( + + #{member.carNumber} + + )} +
+

+ Member since{" "} + {new Date( + member.leagueMemberSince, + ).toLocaleDateString()} +

+
+
+
+ ))} +
+ + {/* Pagination */} + {totalMemberPages > 1 && ( +
+

+ Showing {memberStartIndex + 1}- + {Math.min( + memberStartIndex + membersPerPage, + filteredMembers.length, + )}{" "} + of {filteredMembers.length} + {memberSearch.trim() + ? ` (filtered from ${members.length})` + : ""} +

+
+ + + Page {currentMemberPage} of {totalMemberPages} + + +
+
+ )} + + )} +
+ )} +
+
+ ); +} diff --git a/src/app/app/[leagueId]/admin/page.tsx b/src/app/app/[leagueId]/admin/page.tsx index 40c8f18..77a4273 100644 --- a/src/app/app/[leagueId]/admin/page.tsx +++ b/src/app/app/[leagueId]/admin/page.tsx @@ -1,15 +1,9 @@ "use client"; import Link from "next/link"; -import Image from "next/image"; import { useParams, useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { useAuth } from "@/components/AuthProvider"; -import { AddScheduleModal } from "@/components/AddScheduleModal"; -import { - AdminScheduleSection, - AdminSchedule, -} from "@/components/AdminScheduleSection"; interface LeagueDetail { id: string; @@ -22,167 +16,10 @@ interface LeagueDetail { admin: boolean; } -interface VirtualMoneySettings { - id: string; - virtualModeEnabled: boolean; - virtualBaselinePayout: number[]; - virtualEntryFee: number; - virtualStartingMoney: number; - virtualIncLimit: number; - virtualCarReplaceCost: number; - virtualTeamCost: number; -} - -const VIRTUAL_PAYOUT_SLOTS = 60; - -async function readJsonSafely(response: Response): Promise { - const text = await response.text(); - if (!text) return null; - - try { - return JSON.parse(text) as T; - } catch { - return null; - } -} - -async function getApiErrorMessage(response: Response, fallback: string) { - const payload = await readJsonSafely<{ error?: string; message?: string }>( - response, - ); - - if (payload?.message) return payload.message; - if (payload?.error) return payload.error; - - return fallback; -} - -interface PointsSystem { - id: string; - name: string; - description: string | null; - positionPoints: Record; - bonusPoints: Record; - isDefault: boolean; - isPreset: boolean; - presetType: string | null; - leagueId: string | null; -} - -interface Series { - id: string; - name: string; - description: string | null; - cars: string[]; - isActive: boolean; - pointsSystem: PointsSystem; - createdAt: string; - updatedAt: string; -} - -interface Season { - id: string; - seriesId: string; - iracingSeasonId: number | null; - seasonName: string; - description: string | null; - cars: Array<{ car_id: number; car_name: string }>; - isActive: boolean; - hidden: boolean; - numDrops: number; - noDropsOnOrAfterRaceNum: number; - iracingPointsSystemId: number | null; - iracingPointsSystemName: string | null; - iracingPointsSystemDesc: string | null; - isSynced: boolean; - lastSyncedAt: string | null; - createdAt: string; - updatedAt: string; -} - -interface Helmet { - pattern: number; - color1: string; - color2: string; - color3: string; - face_type: number; - helmet_type: number; -} - -interface Member { - id: string; - custId: number; - displayName: string; - owner: boolean; - admin: boolean; - leagueMailOptOut: boolean | null; - leaguePmOptOut: boolean | null; - leagueMemberSince: string; - carNumber: string | null; - nickName: string | null; - helmet: Helmet; - lastSyncedAt: string; - createdAt: string; - updatedAt: string; -} - -interface EditingSeasonData { - seriesId: string; - seasonId: string; -} - -interface ScheduleWeather { - type: "Set" | "Realistic"; - skies?: "Clear" | "Partly Cloudy" | "Mostly Cloudy" | "Overcast"; - temp?: { unit: "F" | "C"; value: number }; - humidity?: number; - fog?: number; - windDirection?: "N" | "NE" | "E" | "SE" | "S" | "SW" | "W" | "NW"; - windSpeed?: { speed: number; unit: "MPH" | "KPH" }; -} - -interface Schedule { - id: string; - seasonId: string; - seriesId: string; - eventDate: string; - raceName: string; - isOffWeek: boolean; - pointsCount: boolean; - canDrop: boolean; - registrationEnabled: boolean; - trackName?: string; - trackId?: number; - raceLength?: string; - virtualPurse: number; - virtualEntryFee: number; - virtualPayoutSplit: number[]; - stages: Array<{ stageNumber: number; endLap: number }>; - weather: ScheduleWeather; - raceOrder: number; - createdAt: string; - updatedAt: string; -} - -interface IracingSeasonOption { - season_id: number; - season_name: string; - active: boolean; - hidden: boolean; - points_system_name: string; -} - -interface IracingSeasonSessionOption { - session_id: number; - launch_at: string; - race_laps: number; - race_length: number; - time_limit: number; - has_results: boolean; - track?: { - track_id?: number; - track_name?: string; - }; +interface AdminStats { + seriesCount: number; + memberCount: number; + pendingJoinRequests: number; } export default function LeagueAdminPage() { @@ -191,72 +28,13 @@ export default function LeagueAdminPage() { const params = useParams<{ leagueId: string }>(); const [league, setLeague] = useState(null); - const [series, setSeries] = useState([]); - const [pointsSystems, setPointsSystems] = useState([]); + const [stats, setStats] = useState({ + seriesCount: 0, + memberCount: 0, + pendingJoinRequests: 0, + }); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [showCreateSeriesModal, setShowCreateSeriesModal] = useState(false); - const [editingSeries, setEditingSeries] = useState(null); - const [seasonsBySeries, setSeasonsBySeries] = useState< - Record - >({}); - const [syncingSeriesId, setSyncingSeriesId] = useState(null); - const [seasonModalSeries, setSeasonModalSeries] = useState( - null, - ); - const [syncModalSeries, setSyncModalSeries] = useState(null); - const [editingSeasonData, setEditingSeasonData] = - useState(null); - const [members, setMembers] = useState([]); - const [syncingMembers, setSyncingMembers] = useState(false); - const [memberPage, setMemberPage] = useState(1); - const [membersPerPage, setMembersPerPage] = useState(20); - const [memberSearch, setMemberSearch] = useState(""); - const [standingsLimitInput, setStandingsLimitInput] = useState(10); - const [scheduleLimitInput, setScheduleLimitInput] = useState(12); - const [resultsLimitInput, setResultsLimitInput] = useState(20); - const [widgetView, setWidgetView] = useState( - "all" as "all" | "upcoming" | "results" | "standings" | "schedule", - ); - const [widgetPreset, setWidgetPreset] = useState( - "custom" as "custom" | "nascar-red" | "dark-slate" | "light-clean", - ); - const [widgetTheme, setWidgetTheme] = useState("light" as "light" | "dark"); - const [widgetAccentColor, setWidgetAccentColor] = useState("#ef4444"); - const [widgetBgColor, setWidgetBgColor] = useState("#ffffff"); - const [widgetNoBackground, setWidgetNoBackground] = useState(false); - const [widgetCompactMode, setWidgetCompactMode] = useState(false); - const [widgetTextColor, setWidgetTextColor] = useState("#111827"); - const [widgetBorderColor, setWidgetBorderColor] = useState("#e5e7eb"); - const [widgetTargetSelector, setWidgetTargetSelector] = - useState("#irh-widget"); - const [copiedField, setCopiedField] = useState(null); - const [virtualModeEnabled, setVirtualModeEnabled] = useState(false); - const [virtualEntryFee, setVirtualEntryFee] = useState(0); - const [virtualStartingMoney, setVirtualStartingMoney] = useState(0); - const [virtualIncLimit, setVirtualIncLimit] = useState(0); - const [virtualCarReplaceCost, setVirtualCarReplaceCost] = useState(0); - const [virtualTeamCost, setVirtualTeamCost] = useState(0); - const [virtualBaselinePayout, setVirtualBaselinePayout] = useState( - () => Array.from({ length: VIRTUAL_PAYOUT_SLOTS }, () => 0), - ); - const [savingVirtualMoney, setSavingVirtualMoney] = useState(false); - const [virtualMoneyNotice, setVirtualMoneyNotice] = useState( - null, - ); - const [showVirtualMoneyModal, setShowVirtualMoneyModal] = useState(false); - const [pendingIracingLeagueId, setPendingIracingLeagueId] = useState(""); - const [linkingIracingLeague, setLinkingIracingLeague] = useState(false); - - // Results management - // Schedule management - const [schedulesBySeason, setSchedulesBySeason] = useState< - Record - >({}); - const [showScheduleModal, setShowScheduleModal] = useState(false); - const [selectedSeasonForSchedule, setSelectedSeasonForSchedule] = - useState(null); - const [editingSchedule, setEditingSchedule] = useState(null); useEffect(() => { if (!authLoading && !session?.authenticated) { @@ -287,80 +65,35 @@ export default function LeagueAdminPage() { ) ?? null; if (!found) { - setError("League not found or you are not a member."); + setError("League not found"); } else if (!found.owner && !found.admin) { - setError("You do not have admin access to this league."); + setError("You don't have admin access"); } else { setLeague(found); - // Fetch series and points systems - const [seriesRes, pointsRes, membersRes, virtualMoneyRes] = - await Promise.all([ - fetch(`/api/leagues/${found.id}/series`, { cache: "no-store" }), - fetch(`/api/leagues/${found.id}/points-systems`, { - cache: "no-store", - }), - fetch(`/api/leagues/${found.id}/members`, { cache: "no-store" }), - fetch(`/api/leagues/${found.id}/virtual-money`, { - cache: "no-store", - }), - ]); - if (seriesRes.ok) { - const seriesData = (await seriesRes.json()) as Series[]; - setSeries(seriesData); - - const seasonsEntries = await Promise.all( - seriesData.map(async (currentSeries) => { - const seasonsRes = await fetch( - `/api/leagues/${found.id}/series/${currentSeries.id}/seasons`, - { cache: "no-store" }, - ); - if (!seasonsRes.ok) { - return [currentSeries.id, []] as const; - } - - const seasons = (await seasonsRes.json()) as Season[]; - return [currentSeries.id, seasons] as const; - }), - ); - - setSeasonsBySeries(Object.fromEntries(seasonsEntries)); - } - if (pointsRes.ok) - setPointsSystems((await pointsRes.json()) as PointsSystem[]); - if (membersRes.ok) { - setMembers((await membersRes.json()) as Member[]); - setMemberPage(1); - } - if (virtualMoneyRes.ok) { - const virtualMoney = - (await virtualMoneyRes.json()) as VirtualMoneySettings; - setVirtualModeEnabled(virtualMoney.virtualModeEnabled); - setVirtualEntryFee(virtualMoney.virtualEntryFee); - setVirtualStartingMoney(virtualMoney.virtualStartingMoney ?? 0); - setVirtualIncLimit(virtualMoney.virtualIncLimit); - setVirtualCarReplaceCost(virtualMoney.virtualCarReplaceCost ?? 0); - setVirtualTeamCost(virtualMoney.virtualTeamCost); - - const payout = Array.isArray(virtualMoney.virtualBaselinePayout) - ? virtualMoney.virtualBaselinePayout - .slice(0, VIRTUAL_PAYOUT_SLOTS) - .map((amount) => - Number.isFinite(amount) && Number(amount) >= 0 - ? Math.floor(Number(amount)) - : 0, - ) - : []; - - while (payout.length < VIRTUAL_PAYOUT_SLOTS) { - payout.push(0); - } - - setVirtualBaselinePayout(payout); - } + // Load stats + const [seriesRes, membersRes, joinReqRes] = await Promise.all([ + fetch(`/api/leagues/${found.id}/series`, { cache: "no-store" }), + fetch(`/api/leagues/${found.id}/members`, { cache: "no-store" }), + fetch(`/api/leagues/${found.id}/join-requests`, { + cache: "no-store", + }), + ]); + + const seriesData = seriesRes.ok ? await seriesRes.json() : []; + const membersData = membersRes.ok ? await membersRes.json() : []; + const joinReqData = joinReqRes.ok ? await joinReqRes.json() : []; + + setStats({ + seriesCount: Array.isArray(seriesData) ? seriesData.length : 0, + memberCount: Array.isArray(membersData) ? membersData.length : 0, + pendingJoinRequests: Array.isArray(joinReqData) + ? joinReqData.filter((r: any) => r.status === "PENDING").length + : 0, + }); } } catch (err) { - setError(err instanceof Error ? err.message : "unknown_error"); + setError(err instanceof Error ? err.message : "error_loading"); } finally { setLoading(false); } @@ -369,586 +102,6 @@ export default function LeagueAdminPage() { load(); }, [session?.authenticated, params.leagueId]); - const handleCreateOrUpdateSeries = async (data: { - name: string; - description: string; - cars: string[]; - pointsSystemId: string; - isActive?: boolean; - }) => { - if (!league) return; - - try { - const endpoint = editingSeries - ? `/api/leagues/${league.id}/series/${editingSeries.id}` - : `/api/leagues/${league.id}/series`; - - const method = editingSeries ? "PATCH" : "POST"; - const res = await fetch(endpoint, { - method, - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(data), - }); - - if (!res.ok) { - const error = await res.json(); - throw new Error(error.error ?? "failed_to_save"); - } - - const newSeries = (await res.json()) as Series; - if (editingSeries) { - setSeries(series.map((s) => (s.id === newSeries.id ? newSeries : s))); - setEditingSeries(null); - } else { - setSeries([...series, newSeries]); - } - setShowCreateSeriesModal(false); - } catch (err) { - alert(err instanceof Error ? err.message : "error_saving_series"); - } - }; - - const handleRetireSeries = async (seriesId: string) => { - if (!league) return; - if (!confirm("Are you sure you want to retire this series?")) return; - - try { - const res = await fetch(`/api/leagues/${league.id}/series/${seriesId}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ isActive: false }), - }); - - if (!res.ok) throw new Error("failed_to_retire"); - - setSeries( - series.map((s) => (s.id === seriesId ? { ...s, isActive: false } : s)), - ); - } catch (err) { - alert(err instanceof Error ? err.message : "error_retiring_series"); - } - }; - - const refreshSeriesSeasons = async (seriesId: string) => { - if (!league) return; - - const res = await fetch( - `/api/leagues/${league.id}/series/${seriesId}/seasons`, - { - cache: "no-store", - }, - ); - - if (!res.ok) { - throw new Error("failed_to_load_seasons"); - } - - const seasons = (await res.json()) as Season[]; - setSeasonsBySeries((prev) => ({ ...prev, [seriesId]: seasons })); - }; - - const handleSyncSeasons = async ( - seriesId: string, - seasonIds: number[], - sessionIdsBySeason: Record, - ) => { - if (!league) return; - if (seasonIds.length === 0) { - throw new Error("Please select at least one season to sync"); - } - - setSyncingSeriesId(seriesId); - try { - const res = await fetch( - `/api/leagues/${league.id}/series/${seriesId}/seasons/sync`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ seasonIds, sessionIdsBySeason }), - }, - ); - - if (!res.ok) { - const errorMessage = await getApiErrorMessage( - res, - "failed_to_sync_seasons", - ); - throw new Error(errorMessage); - } - - const data = - (await readJsonSafely<{ - syncedCount?: number; - requestedCount?: number; - importedSessionsCount?: number; - }>(res)) ?? {}; - - await refreshSeriesSeasons(seriesId); - alert( - `Synced ${data.syncedCount ?? 0} of ${data.requestedCount ?? seasonIds.length} selected season(s). Imported ${data.importedSessionsCount ?? 0} session(s).`, - ); - } catch (err) { - alert(err instanceof Error ? err.message : "error_syncing_seasons"); - } finally { - setSyncingSeriesId(null); - } - }; - - const handleCreateSeason = async ( - seriesId: string, - data: { seasonName: string; description: string }, - ) => { - if (!league) return; - - const seriesItem = series.find((s) => s.id === seriesId); - if (!seriesItem) throw new Error("series_not_found"); - - const cars = seriesItem.cars.map((carName, index) => ({ - car_id: -(index + 1), - car_name: carName, - })); - - const res = await fetch( - `/api/leagues/${league.id}/series/${seriesId}/seasons`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - seasonName: data.seasonName, - description: data.description, - cars, - }), - }, - ); - - if (!res.ok) { - const errorData = await readJsonSafely<{ - error?: string; - message?: string; - }>(res); - throw new Error( - errorData?.message ?? errorData?.error ?? "failed_to_create_season", - ); - } - - await refreshSeriesSeasons(seriesId); - }; - - const handleDeleteSeason = async (seriesId: string, seasonId: string) => { - if (!league) return; - if ( - !confirm( - "Are you sure you want to delete this season? This cannot be undone.", - ) - ) - return; - - try { - const res = await fetch( - `/api/leagues/${league.id}/series/${seriesId}/seasons/${seasonId}`, - { method: "DELETE" }, - ); - - if (!res.ok) throw new Error("failed_to_delete_season"); - - await refreshSeriesSeasons(seriesId); - } catch (err) { - alert(err instanceof Error ? err.message : "error_deleting_season"); - } - }; - - const handleUpdateSeason = async ( - seriesId: string, - seasonId: string, - data: { description: string }, - ) => { - if (!league) return; - - try { - const res = await fetch( - `/api/leagues/${league.id}/series/${seriesId}/seasons/${seasonId}`, - { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(data), - }, - ); - - if (!res.ok) throw new Error("failed_to_update_season"); - - await refreshSeriesSeasons(seriesId); - } catch (err) { - alert(err instanceof Error ? err.message : "error_updating_season"); - } - }; - - // Schedule management handlers - const refreshSchedules = async (seasonId: string, seriesId: string) => { - if (!league) return; - - try { - const res = await fetch( - `/api/leagues/${league.id}/series/${seriesId}/seasons/${seasonId}/schedules`, - { cache: "no-store" }, - ); - - if (!res.ok) throw new Error("failed_to_fetch_schedules"); - - const schedules = - (await readJsonSafely(res))?.filter(Boolean) ?? []; - setSchedulesBySeason((prev) => ({ - ...prev, - [seasonId]: schedules, - })); - } catch (err) { - console.error("Error fetching schedules:", err); - } - }; - - // Auto-load schedules for all seasons whenever seasonsBySeries changes - useEffect(() => { - if (!league) return; - const allSeasons = Object.values(seasonsBySeries).flat(); - allSeasons.forEach((season) => { - if (!schedulesBySeason[season.id]) { - void refreshSchedules(season.id, season.seriesId); - } - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [seasonsBySeries, league]); - - const openScheduleModal = (season: Season) => { - setSelectedSeasonForSchedule(season); - if (!schedulesBySeason[season.id]) { - refreshSchedules(season.id, season.seriesId); - } - setShowScheduleModal(true); - }; - - const handleSaveSchedule = async ( - data: Omit< - Schedule, - "id" | "createdAt" | "updatedAt" | "seasonId" | "seriesId" - >, - ) => { - if (!league || !selectedSeasonForSchedule) return; - - try { - const url = `/api/leagues/${league.id}/series/${selectedSeasonForSchedule.seriesId}/seasons/${selectedSeasonForSchedule.id}/schedules${editingSchedule ? `/${editingSchedule.id}` : ""}`; - - const res = await fetch(url, { - method: editingSchedule ? "PATCH" : "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(data), - }); - - if (!res.ok) { - const errorMessage = await getApiErrorMessage( - res, - "failed_to_save_schedule", - ); - throw new Error(errorMessage); - } - - await refreshSchedules( - selectedSeasonForSchedule.id, - selectedSeasonForSchedule.seriesId, - ); - } catch (err) { - throw err; - } - }; - - const handleDeleteSchedule = async (seasonId: string, scheduleId: string) => { - if (!league) return; - - const season = Object.values(seasonsBySeries) - .flat() - .find((s) => s.id === seasonId); - if (!season) return; - - if (!confirm("Delete this race from the schedule?")) return; - - try { - const res = await fetch( - `/api/leagues/${league.id}/series/${season.seriesId}/seasons/${seasonId}/schedules/${scheduleId}`, - { method: "DELETE" }, - ); - - if (!res.ok) throw new Error("failed_to_delete_schedule"); - - await refreshSchedules(seasonId, season.seriesId); - } catch (err) { - alert(err instanceof Error ? err.message : "error_deleting_schedule"); - } - }; - - const handleSyncMembers = async () => { - if (!league) return; - - setSyncingMembers(true); - try { - const res = await fetch(`/api/leagues/${league.id}/members/sync`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - }); - - if (!res.ok) { - const errorMessage = await getApiErrorMessage( - res, - "failed_to_sync_members", - ); - throw new Error(errorMessage); - } - - const data = - (await readJsonSafely<{ - syncedCount?: number; - totalMembers?: number; - failedCount?: number; - removedCount?: number; - }>(res)) ?? {}; - - // Reload members - const membersRes = await fetch(`/api/leagues/${league.id}/members`, { - cache: "no-store", - }); - if (membersRes.ok) { - setMembers((await membersRes.json()) as Member[]); - setMemberPage(1); - } - - const removedMsg = data.removedCount - ? ` Removed ${data.removedCount} member${data.removedCount !== 1 ? "s" : ""} no longer on roster.` - : ""; - const failedMsg = data.failedCount ? ` ${data.failedCount} failed.` : ""; - alert( - `Synced ${data.syncedCount ?? 0} of ${data.totalMembers ?? 0} members.${removedMsg}${failedMsg}`, - ); - } catch (err) { - alert(err instanceof Error ? err.message : "error_syncing_members"); - } finally { - setSyncingMembers(false); - } - }; - - const handleSaveVirtualMoney = async () => { - if (!league) return; - - setSavingVirtualMoney(true); - setVirtualMoneyNotice(null); - - try { - const payload = { - virtualModeEnabled, - virtualEntryFee: Math.max(0, Math.floor(virtualEntryFee)), - virtualStartingMoney: Math.max(0, Math.floor(virtualStartingMoney)), - virtualIncLimit: Math.max(0, Math.floor(virtualIncLimit)), - virtualCarReplaceCost: Math.max(0, Math.floor(virtualCarReplaceCost)), - virtualTeamCost: Math.max(0, Math.floor(virtualTeamCost)), - virtualBaselinePayout, - }; - - const res = await fetch(`/api/leagues/${league.id}/virtual-money`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - - if (!res.ok) { - const errorData = (await res.json()) as { error?: string }; - throw new Error(errorData.error ?? "failed_to_save_virtual_money"); - } - - const updated = (await res.json()) as VirtualMoneySettings; - setVirtualModeEnabled(updated.virtualModeEnabled); - setVirtualEntryFee(updated.virtualEntryFee); - setVirtualStartingMoney(updated.virtualStartingMoney ?? 0); - setVirtualIncLimit(updated.virtualIncLimit); - setVirtualCarReplaceCost(updated.virtualCarReplaceCost ?? 0); - setVirtualTeamCost(updated.virtualTeamCost); - setVirtualBaselinePayout( - updated.virtualBaselinePayout - .slice(0, VIRTUAL_PAYOUT_SLOTS) - .map((amount) => (Number.isFinite(amount) ? Math.max(0, amount) : 0)), - ); - setVirtualMoneyNotice("Virtual money settings saved."); - } catch (err) { - setVirtualMoneyNotice( - err instanceof Error ? err.message : "error_saving_virtual_money", - ); - } finally { - setSavingVirtualMoney(false); - } - }; - - const clampInt = (value: number, min: number, max: number) => - Math.min(max, Math.max(min, value)); - - const applyWidgetPreset = ( - preset: "custom" | "nascar-red" | "dark-slate" | "light-clean", - ) => { - setWidgetPreset(preset); - - if (preset === "custom") { - return; - } - - if (preset === "nascar-red") { - setWidgetTheme("dark"); - setWidgetAccentColor("#ef4444"); - setWidgetBgColor("#0a0a0a"); - setWidgetNoBackground(false); - setWidgetTextColor("#f3f4f6"); - setWidgetBorderColor("#3f3f46"); - return; - } - - if (preset === "dark-slate") { - setWidgetTheme("dark"); - setWidgetAccentColor("#38bdf8"); - setWidgetBgColor("#0f172a"); - setWidgetNoBackground(false); - setWidgetTextColor("#e2e8f0"); - setWidgetBorderColor("#334155"); - return; - } - - setWidgetTheme("light"); - setWidgetAccentColor("#2563eb"); - setWidgetBgColor("#ffffff"); - setWidgetNoBackground(false); - setWidgetTextColor("#111827"); - setWidgetBorderColor("#e5e7eb"); - }; - - const standingsLimit = clampInt(standingsLimitInput, 1, 50); - const scheduleLimit = clampInt(scheduleLimitInput, 1, 50); - const resultsLimit = clampInt(resultsLimitInput, 1, 100); - const widgetOrigin = - typeof window === "undefined" ? "" : window.location.origin; - - const widgetLeagueId = league ? league.routeLeagueId : ""; - const widgetQueryParams = new URLSearchParams({ - standingsLimit: String(standingsLimit), - scheduleLimit: String(scheduleLimit), - resultsLimit: String(resultsLimit), - view: widgetView, - theme: widgetTheme, - accent: widgetAccentColor, - bg: widgetNoBackground ? "transparent" : widgetBgColor, - text: widgetTextColor, - border: widgetBorderColor, - compact: String(widgetCompactMode), - }); - const widgetQuery = widgetQueryParams.toString(); - - const feedPath = `/api/widgets/leagues/${widgetLeagueId}?${widgetQuery}`; - const embedPath = `/api/widgets/leagues/${widgetLeagueId}/embed?${widgetQuery}`; - const feedUrl = widgetOrigin ? `${widgetOrigin}${feedPath}` : feedPath; - const embedUrl = widgetOrigin ? `${widgetOrigin}${embedPath}` : embedPath; - - const getEmbedUrlForView = ( - view: "all" | "upcoming" | "results" | "standings" | "schedule", - ) => { - const params = new URLSearchParams(widgetQueryParams); - params.set("view", view); - const path = `/api/widgets/leagues/${widgetLeagueId}/embed?${params.toString()}`; - return widgetOrigin ? `${widgetOrigin}${path}` : path; - }; - - const getEmbedCodeForView = ( - view: "all" | "upcoming" | "results" | "standings" | "schedule", - ) => { - const viewEmbedUrl = getEmbedUrlForView(view); - return [ - '
', - ``, - ].join("\n"); - }; - const previewEmbedUrl = embedUrl - .replaceAll("&", "&") - .replaceAll('"', """); - const widgetPreviewSrcDoc = ` - - - - - - -
- - -`; - const embedCode = getEmbedCodeForView(widgetView); - - const copyText = async (label: string, value: string) => { - try { - await navigator.clipboard.writeText(value); - setCopiedField(label); - setTimeout( - () => setCopiedField((current) => (current === label ? null : current)), - 1800, - ); - } catch { - alert("Unable to copy to clipboard."); - } - }; - - 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 (
@@ -959,37 +112,42 @@ export default function LeagueAdminPage() { if (!session?.authenticated) return null; - const normalizedMemberSearch = memberSearch.trim().toLowerCase(); - const filteredMembers = members.filter((member) => { - if (!normalizedMemberSearch) return true; - - const searchable = [ - member.displayName, - member.nickName ?? "", - member.carNumber ?? "", - String(member.custId), - ] - .join(" ") - .toLowerCase(); - - return searchable.includes(normalizedMemberSearch); - }); - - const totalMemberPages = Math.max( - 1, - Math.ceil(filteredMembers.length / membersPerPage), - ); - const currentMemberPage = Math.min(memberPage, totalMemberPages); - const memberStartIndex = (currentMemberPage - 1) * membersPerPage; - const paginatedMembers = filteredMembers.slice( - memberStartIndex, - memberStartIndex + membersPerPage, - ); - const showingFrom = filteredMembers.length === 0 ? 0 : memberStartIndex + 1; - const showingTo = Math.min( - memberStartIndex + membersPerPage, - filteredMembers.length, - ); + const adminSections = [ + { + title: "Series & Seasons", + description: "Manage racing series, seasons, and schedules", + emoji: "πŸ†", + href: `/app/${league?.routeLeagueId}/admin/series`, + stat: 0, + statLabel: "series", + }, + { + title: "Members", + description: "View and manage league members", + emoji: "πŸ‘₯", + href: `/app/${league?.routeLeagueId}/admin/members`, + stat: 0, + statLabel: "members", + }, + { + title: "Points Systems", + description: "Create and manage scoring systems", + emoji: "πŸ“Š", + href: `/app/${league?.routeLeagueId}/admin/points-system`, + }, + { + title: "Widgets", + description: "Generate embeddable league widgets", + emoji: "πŸ”—", + href: `/app/${league?.routeLeagueId}/admin/widgets`, + }, + { + title: "Settings", + description: "Configure virtual money, recruiting, and more", + emoji: "βš™οΈ", + href: `/app/${league?.routeLeagueId}/admin/settings`, + }, + ]; return (
@@ -1010,12 +168,6 @@ export default function LeagueAdminPage() { ← League View )} - - Dashboard - -
-
- )} - -
-
-

Virtual Money

-

- Configure league-level economy settings. Race purse and - payout split are set per event during schedule creation. -

-
- - Mode: {virtualModeEnabled ? "On" : "Off"} - - - Entry: ${virtualEntryFee} - - - Start: ${virtualStartingMoney} - - - INC Limit: {virtualIncLimit} - - - Car Replace: ${virtualCarReplaceCost} - - - Team Cost: ${virtualTeamCost} +
+

+ {league.leagueName} +

+ + Admin
+

+ {league.iracingLeagueId + ? `iRacing League ID: ${league.iracingLeagueId}` + : "Not linked to iRacing"} + {league.rosterCount + ? ` Β· ${league.rosterCount} members` + : ""} +

+

+ Role:{" "} + + {league.owner ? "Owner" : "Admin"} + +

-
- {/* Points Systems Section */} -
-
-

Points Systems

+ {/* Admin Sections Grid */} +
+ {adminSections.map((section) => ( - + Create Custom System - -
+
{section.emoji}
-
- {pointsSystems - .filter((ps) => ps.leagueId === null) // Show only preset systems - .slice(0, 6) // Limit to 6 presets - .map((ps) => ( -
-
-

{ps.name}

- {ps.isPreset && ( - - {ps.presetType?.toUpperCase()} - - )} -
- {ps.description && ( -

- {ps.description} -

- )} -
- Position points set for top finishers -
-
- ))} -
- - {pointsSystems.some((ps) => ps.leagueId === params.leagueId) ? ( -
-

- Custom Systems +

+ {section.title}

-
- {pointsSystems - .filter((ps) => ps.leagueId === params.leagueId) - .map((ps) => ( -
-

- {ps.name} -

- {ps.description && ( -

- {ps.description} -

- )} -
- ))} -
-
- ) : null} -
- - {/* Series Management Section */} -
-
-

Series

- -
- - {series.length === 0 ? ( -
-

- No series created yet -

- -
- ) : ( -
- {series.map((s) => ( -
-
-
-
-

{s.name}

- {s.isActive && ( - - Active - - )} -
- {s.description && ( -

- {s.description} -

- )} -
-
- {!s.isActive && ( - - Retired - - )} - - {(seasonsBySeries[s.id] ?? []).length} season - {(seasonsBySeries[s.id] ?? []).length !== 1 - ? "s" - : ""} - -
-
- -
-
- - Cars - -

- {s.cars.length > 0 - ? `${s.cars.length} car${s.cars.length !== 1 ? "s" : ""}` - : "None"} -

-
-
- - Points System - -

- {s.pointsSystem.name} -

-
-
- - Created - -

- {new Date(s.createdAt).toLocaleDateString()} -

-
-
- - {s.isActive && ( -
- - -
- )} - -
-
-

- Seasons ({(seasonsBySeries[s.id] ?? []).length}) -

-
- - -
-
+

{section.description}

- {(seasonsBySeries[s.id] ?? []).length === 0 ? ( -
-

- No seasons yet. Create a custom one - {league.iracingLeagueId == null - ? ". Link an iRacing league ID above to enable syncing." - : " or sync from iRacing."} -

-
- ) : ( -
- {(seasonsBySeries[s.id] ?? []).map((season) => ( -
-
-
-

- {season.seasonName} -

-
- - {season.cars?.length ?? 0} cars - - {season.iracingPointsSystemName && ( - <> - β€’ - - {season.iracingPointsSystemName} - - - )} - {season.lastSyncedAt && ( - <> - β€’ - - Synced{" "} - {new Date( - season.lastSyncedAt, - ).toLocaleDateString()} - - - )} -
-
-
- - {season.isSynced ? "Synced" : "Custom"} - - {!season.isActive && ( - - Inactive - - )} -
-
- -
- - -
- - {/* Schedule management β€” expandable events with inline results & import */} - {league && ( - { - setEditingSchedule(null); - openScheduleModal(season); - }} - onEditSchedule={(schedule) => { - setEditingSchedule( - schedule as unknown as Schedule, - ); - openScheduleModal(season); - }} - onDeleteSchedule={(schedule) => - handleDeleteSchedule( - season.id, - schedule.id, - ) - } - onRefresh={() => - refreshSchedules(season.id, s.id) - } - /> - )} -
- ))} -
- )} -
-
- ))} -
- )} -
- - {/* Members Management Section */} -
-
-
-

Widgets

-

- Generate league widget links and embeddable code. -

-
-
- -
-
- - - - - - - -
- -
- - - - -
- - - - - -
-
-

- Feed URL -

- -
-