feat: Implement tournament schedule tab and fix E2E tests #27
@@ -0,0 +1,271 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { canManageTournament } from "@/lib/permissions"
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{
|
||||
id: string
|
||||
}>
|
||||
}
|
||||
|
||||
type TeamDurability = 'permanent' | 'variable' | 'per_round'
|
||||
type PartnerRotation = 'none' | 'minimize_repeat' | 'maximize_even' | 'elo_based'
|
||||
|
||||
interface Team {
|
||||
player1Id: number
|
||||
player2Id: number
|
||||
teamName: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/tournaments/[id]/teams/generate
|
||||
*
|
||||
* Generate teams for a tournament based on configuration.
|
||||
* Supports multiple team generation strategies.
|
||||
*/
|
||||
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 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { teamDurability, partnerRotation, allowByes } = body as {
|
||||
teamDurability: TeamDurability
|
||||
partnerRotation: PartnerRotation
|
||||
allowByes: boolean
|
||||
}
|
||||
|
||||
// Validate configuration
|
||||
if (!teamDurability || !partnerRotation) {
|
||||
return NextResponse.json(
|
||||
{ error: "teamDurability and partnerRotation are required" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get tournament and participants
|
||||
const tournament = await prisma.event.findUnique({
|
||||
where: { id: tournamentId },
|
||||
include: {
|
||||
participants: {
|
||||
include: {
|
||||
player: true,
|
||||
},
|
||||
},
|
||||
teams: true,
|
||||
rounds: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!tournament) {
|
||||
return NextResponse.json(
|
||||
{ error: "Tournament not found" },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if schedule exists (can't generate teams if schedule exists)
|
||||
if (tournament.rounds.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Cannot generate teams after schedule has been created. Delete schedule first." },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check participant count
|
||||
const participants = tournament.participants
|
||||
if (participants.length < 2) {
|
||||
return NextResponse.json(
|
||||
{ error: "At least 2 participants are required to generate teams" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Generate teams based on strategy
|
||||
const teams = generateTeams(participants, partnerRotation, allowByes)
|
||||
|
||||
// Delete existing teams
|
||||
await prisma.team.deleteMany({
|
||||
where: { eventId: tournamentId },
|
||||
})
|
||||
|
||||
// Create new teams
|
||||
const createdTeams = await Promise.all(
|
||||
teams.map((team, index) =>
|
||||
prisma.team.create({
|
||||
data: {
|
||||
eventId: tournamentId,
|
||||
player1Id: team.player1Id,
|
||||
player2Id: team.player2Id,
|
||||
teamName: team.teamName || `Team ${index + 1}`,
|
||||
},
|
||||
include: {
|
||||
player1: true,
|
||||
player2: true,
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// Update tournament configuration
|
||||
await prisma.event.update({
|
||||
where: { id: tournamentId },
|
||||
data: {
|
||||
teamDurability,
|
||||
partnerRotation,
|
||||
allowByes,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
teams: createdTeams,
|
||||
teamsCreated: createdTeams.length,
|
||||
strategy: partnerRotation,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
console.error("Error generating teams:", error)
|
||||
const message = error instanceof Error ? error.message : "Failed to generate teams"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate teams based on partner rotation strategy
|
||||
*/
|
||||
function generateTeams(
|
||||
participants: Array<{ player: { id: number; currentElo: number; name: string } }>,
|
||||
strategy: PartnerRotation,
|
||||
allowByes: boolean
|
||||
): Team[] {
|
||||
const players = participants.map(p => p.player)
|
||||
|
||||
// Handle odd number of players
|
||||
if (players.length % 2 !== 0) {
|
||||
if (!allowByes) {
|
||||
throw new Error("Odd number of participants. Enable 'Allow Byes' to proceed.")
|
||||
}
|
||||
// Remove the player with the lowest ELO for bye
|
||||
players.sort((a, b) => a.currentElo - b.currentElo)
|
||||
players.pop() // Remove last (lowest ELO)
|
||||
}
|
||||
|
||||
switch (strategy) {
|
||||
case 'none': // Random pairing
|
||||
return generateRandomTeams(players)
|
||||
|
||||
case 'minimize_repeat':
|
||||
// For initial generation, we can't minimize repeats since there are no previous teams
|
||||
// So we just generate random teams
|
||||
return generateRandomTeams(players)
|
||||
|
||||
case 'maximize_even':
|
||||
// Pair players to maximize competitive balance
|
||||
return generateEvenTeams(players)
|
||||
|
||||
case 'elo_based':
|
||||
// Pair strongest with weakest
|
||||
return generateELOBasedTeams(players)
|
||||
|
||||
default:
|
||||
return generateRandomTeams(players)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random teams
|
||||
*/
|
||||
function generateRandomTeams(players: Array<{ id: number; currentElo: number; name: string }>): Team[] {
|
||||
const shuffled = [...players]
|
||||
|
||||
// Fisher-Yates shuffle
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
|
||||
}
|
||||
|
||||
const teams: Team[] = []
|
||||
for (let i = 0; i < shuffled.length - 1; i += 2) {
|
||||
teams.push({
|
||||
player1Id: shuffled[i].id,
|
||||
player2Id: shuffled[i + 1].id,
|
||||
teamName: `${shuffled[i].name} & ${shuffled[i + 1].name}`,
|
||||
})
|
||||
}
|
||||
|
||||
return teams
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate teams to maximize competitive balance
|
||||
*/
|
||||
function generateEvenTeams(players: Array<{ id: number; currentElo: number; name: string }>): Team[] {
|
||||
// Sort by ELO
|
||||
const sorted = [...players].sort((a, b) => b.currentElo - a.currentElo)
|
||||
|
||||
// Split into two halves
|
||||
const midpoint = Math.floor(sorted.length / 2)
|
||||
const topHalf = sorted.slice(0, midpoint)
|
||||
const bottomHalf = sorted.slice(midpoint)
|
||||
|
||||
const teams: Team[] = []
|
||||
|
||||
// Pair each top player with a bottom player
|
||||
for (let i = 0; i < Math.min(topHalf.length, bottomHalf.length); i++) {
|
||||
teams.push({
|
||||
player1Id: topHalf[i].id,
|
||||
player2Id: bottomHalf[i].id,
|
||||
teamName: `${topHalf[i].name} & ${bottomHalf[i].name}`,
|
||||
})
|
||||
}
|
||||
|
||||
// Handle odd number of teams
|
||||
if (topHalf.length > bottomHalf.length) {
|
||||
teams.push({
|
||||
player1Id: topHalf[topHalf.length - 1].id,
|
||||
player2Id: topHalf[topHalf.length - 2].id,
|
||||
teamName: `${topHalf[topHalf.length - 1].name} & ${topHalf[topHalf.length - 2].name}`,
|
||||
})
|
||||
}
|
||||
|
||||
return teams
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate ELO-based teams (strongest + weakest)
|
||||
*/
|
||||
function generateELOBasedTeams(players: Array<{ id: number; currentElo: number; name: string }>): Team[] {
|
||||
// Sort by ELO
|
||||
const sorted = [...players].sort((a, b) => b.currentElo - a.currentElo)
|
||||
|
||||
const teams: Team[] = []
|
||||
|
||||
// Pair strongest with weakest
|
||||
for (let i = 0; i < sorted.length / 2; i++) {
|
||||
const j = sorted.length - 1 - i
|
||||
if (i >= j) break
|
||||
|
||||
teams.push({
|
||||
player1Id: sorted[i].id,
|
||||
player2Id: sorted[j].id,
|
||||
teamName: `${sorted[i].name} & ${sorted[j].name}`,
|
||||
})
|
||||
}
|
||||
|
||||
return teams
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { canManageTournament } from "@/lib/permissions"
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{
|
||||
id: string
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/tournaments/[id]/teams
|
||||
*
|
||||
* Delete all teams for a tournament.
|
||||
* This will also delete any associated schedule.
|
||||
*/
|
||||
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 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if 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 exists
|
||||
if (tournament.rounds.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Cannot delete teams while schedule exists. Delete schedule first." },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// Delete all teams
|
||||
const deletedTeams = await prisma.team.deleteMany({
|
||||
where: { eventId: tournamentId },
|
||||
})
|
||||
|
||||
// Reset team configuration
|
||||
await prisma.event.update({
|
||||
where: { id: tournamentId },
|
||||
data: {
|
||||
teamDurability: "permanent",
|
||||
partnerRotation: "none",
|
||||
allowByes: true,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
deletedTeams: deletedTeams.count,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
console.error("Error deleting teams:", error)
|
||||
const message = error instanceof Error ? error.message : "Failed to delete teams"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/tournaments/[id]/teams
|
||||
*
|
||||
* Get all teams for a tournament.
|
||||
*/
|
||||
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 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get tournament and teams
|
||||
const tournament = await prisma.event.findUnique({
|
||||
where: { id: tournamentId },
|
||||
include: {
|
||||
teams: {
|
||||
include: {
|
||||
player1: true,
|
||||
player2: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!tournament) {
|
||||
return NextResponse.json(
|
||||
{ error: "Tournament not found" },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
teams: tournament.teams,
|
||||
teamDurability: tournament.teamDurability,
|
||||
partnerRotation: tournament.partnerRotation,
|
||||
allowByes: tournament.allowByes,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
console.error("Error fetching teams:", error)
|
||||
const message = error instanceof Error ? error.message : "Failed to fetch teams"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user