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.
This commit is contained in:
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user