From 84afa88ca451b908f96b4151d74717e40e30a25e Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Thu, 2 Apr 2026 00:59:32 -0700 Subject: [PATCH] feat: add schedule API endpoints GET returns rounds with matchups for a tournament. POST generates a round-robin schedule from registered teams. DELETE removes all rounds and matchups. All endpoints enforce tournament admin permissions via canManageTournament. --- .../api/tournaments/[id]/schedule/route.ts | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 src/app/api/tournaments/[id]/schedule/route.ts diff --git a/src/app/api/tournaments/[id]/schedule/route.ts b/src/app/api/tournaments/[id]/schedule/route.ts new file mode 100644 index 0000000..07306b3 --- /dev/null +++ b/src/app/api/tournaments/[id]/schedule/route.ts @@ -0,0 +1,239 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { canManageTournament } from "@/lib/permissions"; +import { generateRoundRobin, validateScheduleInput } from "@/lib/schedule-generator"; + +interface RouteParams { + params: Promise<{ + id: string; + }>; +} + +/** + * GET /api/tournaments/[id]/schedule + * + * Fetch the tournament schedule (rounds with matchups). + */ +export async function GET(_request: Request, { params }: RouteParams) { + try { + const { id } = await params; + const tournamentId = parseInt(id); + + if (isNaN(tournamentId)) { + return NextResponse.json( + { error: "Invalid tournament ID" }, + { status: 400 } + ); + } + + const permission = await canManageTournament(tournamentId); + if (!permission.allowed) { + return NextResponse.json( + { error: permission.reason || "Not authorized to view this tournament" }, + { status: 403 } + ); + } + + const tournament = await prisma.event.findUnique({ + where: { id: tournamentId }, + include: { + rounds: { + orderBy: { roundNumber: "asc" }, + include: { + bracketMatchups: { + include: { + team1: { + include: { player1: true, player2: true }, + }, + team2: { + include: { player1: true, player2: true }, + }, + match: true, + }, + }, + }, + }, + }, + }); + + if (!tournament) { + return NextResponse.json( + { error: "Tournament not found" }, + { status: 404 } + ); + } + + return NextResponse.json({ rounds: tournament.rounds }); + } catch (error: unknown) { + console.error("Error fetching schedule:", error); + const message = + error instanceof Error ? error.message : "Failed to fetch schedule"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +/** + * POST /api/tournaments/[id]/schedule + * + * Generate a round-robin schedule for the tournament. + * Creates TournamentRound and BracketMatchup records. + */ +export async function POST(_request: Request, { params }: RouteParams) { + try { + const { id } = await params; + const tournamentId = parseInt(id); + + if (isNaN(tournamentId)) { + return NextResponse.json( + { error: "Invalid tournament ID" }, + { status: 400 } + ); + } + + const permission = await canManageTournament(tournamentId); + if (!permission.allowed) { + return NextResponse.json( + { error: permission.reason || "Not authorized to manage this tournament" }, + { status: 403 } + ); + } + + // Check tournament exists + const tournament = await prisma.event.findUnique({ + where: { id: tournamentId }, + include: { + teams: true, + rounds: true, + }, + }); + + if (!tournament) { + return NextResponse.json( + { error: "Tournament not found" }, + { status: 404 } + ); + } + + // Check if schedule already exists + if (tournament.rounds.length > 0) { + return NextResponse.json( + { + error: "Schedule already exists. Delete existing rounds before regenerating.", + existingRounds: tournament.rounds.length, + }, + { status: 409 } + ); + } + + // Get team IDs + const teamIds = tournament.teams.map((t) => t.id); + + const validation = validateScheduleInput(teamIds); + if (!validation.valid) { + return NextResponse.json( + { error: validation.error }, + { status: 400 } + ); + } + + // Generate schedule + const schedule = generateRoundRobin(teamIds); + + // Create rounds and matchups in a transaction + const created = await prisma.$transaction( + schedule.map((round) => + prisma.tournamentRound.create({ + data: { + eventId: tournamentId, + roundNumber: round.roundNumber, + status: "pending", + bracketMatchups: { + create: round.matchups.map((matchup, idx) => ({ + eventId: tournamentId, + team1Id: matchup.team1Id, + team2Id: matchup.team2Id, + bracketPosition: idx + 1, + status: "pending", + })), + }, + }, + include: { + bracketMatchups: { + include: { + team1: { + include: { player1: true, player2: true }, + }, + team2: { + include: { player1: true, player2: true }, + }, + }, + }, + }, + }) + ) + ); + + return NextResponse.json({ + success: true, + roundsCreated: created.length, + matchupsCreated: created.reduce( + (sum, r) => sum + r.bracketMatchups.length, + 0 + ), + rounds: created, + }); + } catch (error: unknown) { + console.error("Error generating schedule:", error); + const message = + error instanceof Error ? error.message : "Failed to generate schedule"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +/** + * DELETE /api/tournaments/[id]/schedule + * + * Delete all rounds and matchups for a tournament. + */ +export async function DELETE(_request: Request, { params }: RouteParams) { + try { + const { id } = await params; + const tournamentId = parseInt(id); + + if (isNaN(tournamentId)) { + return NextResponse.json( + { error: "Invalid tournament ID" }, + { status: 400 } + ); + } + + const permission = await canManageTournament(tournamentId); + if (!permission.allowed) { + return NextResponse.json( + { error: permission.reason || "Not authorized to manage this tournament" }, + { status: 403 } + ); + } + + // Delete bracket matchups first (FK constraint) + const deletedMatchups = await prisma.bracketMatchup.deleteMany({ + where: { eventId: tournamentId }, + }); + + // Delete rounds + const deletedRounds = await prisma.tournamentRound.deleteMany({ + where: { eventId: tournamentId }, + }); + + return NextResponse.json({ + success: true, + deletedRounds: deletedRounds.count, + deletedMatchups: deletedMatchups.count, + }); + } catch (error: unknown) { + console.error("Error deleting schedule:", error); + const message = + error instanceof Error ? error.message : "Failed to delete schedule"; + return NextResponse.json({ error: message }, { status: 500 }); + } +}