Create comprehensive tournament management API with: - GET/POST /api/tournaments for listing and creating tournaments - GET/PUT/DELETE /api/tournaments/[id] for tournament CRUD operations - GET/POST /api/tournaments/[id]/inscriptions for team registration - PUT/DELETE /api/tournaments/[id]/inscriptions/[inscriptionId] for inscription management - POST /api/tournaments/[id]/generate-bracket for single elimination bracket generation - GET/PUT /api/tournaments/[id]/matches/[matchId] for match results and automatic winner advancement Features bracket generation with proper seeding, bye handling for non-power-of-2 team counts, and automatic winner advancement to next round matches. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
348 lines
9.8 KiB
TypeScript
348 lines
9.8 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { getServerSession } from 'next-auth';
|
|
import { authOptions } from '@/lib/auth';
|
|
import { db } from '@/lib/db';
|
|
|
|
interface RouteContext {
|
|
params: Promise<{ id: string }>;
|
|
}
|
|
|
|
interface Team {
|
|
id: string;
|
|
clientId: string;
|
|
partnerId: string | null;
|
|
teamName: string | null;
|
|
seedNumber: number | null;
|
|
client: {
|
|
id: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
};
|
|
}
|
|
|
|
interface MatchData {
|
|
tournamentId: string;
|
|
round: number;
|
|
position: number;
|
|
status: 'SCHEDULED' | 'IN_PROGRESS' | 'COMPLETED' | 'CANCELLED' | 'WALKOVER';
|
|
team1Players: string[];
|
|
team2Players: string[];
|
|
}
|
|
|
|
// Calculate next power of 2 that is >= n
|
|
function nextPowerOf2(n: number): number {
|
|
let power = 1;
|
|
while (power < n) {
|
|
power *= 2;
|
|
}
|
|
return power;
|
|
}
|
|
|
|
// Calculate number of rounds for single elimination
|
|
function calculateRounds(teamCount: number): number {
|
|
return Math.ceil(Math.log2(teamCount));
|
|
}
|
|
|
|
// Generate single elimination bracket
|
|
function generateSingleEliminationBracket(
|
|
tournamentId: string,
|
|
teams: Team[]
|
|
): MatchData[] {
|
|
const teamCount = teams.length;
|
|
const bracketSize = nextPowerOf2(teamCount);
|
|
const totalRounds = calculateRounds(bracketSize);
|
|
const byeCount = bracketSize - teamCount;
|
|
|
|
const matches: MatchData[] = [];
|
|
|
|
// Sort teams by seed number (if available) for proper seeding
|
|
const sortedTeams = [...teams].sort((a, b) => {
|
|
if (a.seedNumber === null && b.seedNumber === null) return 0;
|
|
if (a.seedNumber === null) return 1;
|
|
if (b.seedNumber === null) return -1;
|
|
return a.seedNumber - b.seedNumber;
|
|
});
|
|
|
|
// Create seeding positions for standard bracket seeding
|
|
// For 8 teams: [1,8,4,5,2,7,3,6] - this ensures top seeds meet later
|
|
const seedingOrder = createSeedingOrder(bracketSize);
|
|
|
|
// Map teams to their seeding positions
|
|
const teamSlots: (Team | null)[] = new Array(bracketSize).fill(null);
|
|
|
|
for (let i = 0; i < sortedTeams.length; i++) {
|
|
const position = seedingOrder[i] - 1; // Convert to 0-indexed
|
|
teamSlots[position] = sortedTeams[i];
|
|
}
|
|
|
|
// First round matches (round 1)
|
|
const firstRoundMatchCount = bracketSize / 2;
|
|
|
|
for (let pos = 0; pos < firstRoundMatchCount; pos++) {
|
|
const team1Index = pos * 2;
|
|
const team2Index = pos * 2 + 1;
|
|
const team1 = teamSlots[team1Index];
|
|
const team2 = teamSlots[team2Index];
|
|
|
|
const match: MatchData = {
|
|
tournamentId,
|
|
round: 1,
|
|
position: pos + 1,
|
|
status: 'SCHEDULED',
|
|
team1Players: team1 ? getTeamPlayerIds(team1) : [],
|
|
team2Players: team2 ? getTeamPlayerIds(team2) : [],
|
|
};
|
|
|
|
// If one team is a bye (null), mark as walkover and advance the other team
|
|
if (!team1 && team2) {
|
|
match.status = 'WALKOVER';
|
|
match.team1Players = []; // Bye
|
|
} else if (team1 && !team2) {
|
|
match.status = 'WALKOVER';
|
|
match.team2Players = []; // Bye
|
|
} else if (!team1 && !team2) {
|
|
// Both are byes - this shouldn't happen with proper seeding
|
|
match.status = 'WALKOVER';
|
|
}
|
|
|
|
matches.push(match);
|
|
}
|
|
|
|
// Create subsequent round matches (empty, to be filled as tournament progresses)
|
|
for (let round = 2; round <= totalRounds; round++) {
|
|
const matchesInRound = bracketSize / Math.pow(2, round);
|
|
|
|
for (let pos = 0; pos < matchesInRound; pos++) {
|
|
matches.push({
|
|
tournamentId,
|
|
round,
|
|
position: pos + 1,
|
|
status: 'SCHEDULED',
|
|
team1Players: [],
|
|
team2Players: [],
|
|
});
|
|
}
|
|
}
|
|
|
|
return matches;
|
|
}
|
|
|
|
// Get player IDs for a team (client + partner if exists)
|
|
function getTeamPlayerIds(team: Team): string[] {
|
|
const ids = [team.clientId];
|
|
if (team.partnerId) {
|
|
ids.push(team.partnerId);
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
// Create standard bracket seeding order
|
|
// For power of 2 brackets, ensures that #1 seed meets #2 seed only in finals
|
|
function createSeedingOrder(size: number): number[] {
|
|
if (size === 2) return [1, 2];
|
|
|
|
const order: number[] = [];
|
|
const halfSize = size / 2;
|
|
const prevOrder = createSeedingOrder(halfSize);
|
|
|
|
for (let i = 0; i < halfSize; i++) {
|
|
order.push(prevOrder[i]);
|
|
order.push(size + 1 - prevOrder[i]);
|
|
}
|
|
|
|
return order;
|
|
}
|
|
|
|
// Process walkover matches and advance winners
|
|
async function processWalkovers(
|
|
tx: Omit<typeof db, '$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends'>,
|
|
matches: { id: string; round: number; position: number; status: string; team1Players: string[]; team2Players: string[] }[]
|
|
): Promise<void> {
|
|
const walkovers = matches.filter(m => m.status === 'WALKOVER');
|
|
|
|
for (const match of walkovers) {
|
|
// Determine winner (the team that's not empty)
|
|
const winner = match.team1Players.length > 0 ? match.team1Players : match.team2Players;
|
|
|
|
if (winner.length === 0) continue; // Both empty, no winner to advance
|
|
|
|
// Find the next round match this winner advances to
|
|
const nextRound = match.round + 1;
|
|
const nextPosition = Math.ceil(match.position / 2);
|
|
|
|
const nextMatch = matches.find(m => m.round === nextRound && m.position === nextPosition);
|
|
if (!nextMatch) continue; // Final match or not found
|
|
|
|
// Determine if this match feeds into team1 or team2 of next match
|
|
const isUpperBracket = match.position % 2 === 1;
|
|
|
|
if (isUpperBracket) {
|
|
await tx.match.update({
|
|
where: { id: nextMatch.id },
|
|
data: { team1Players: winner },
|
|
});
|
|
} else {
|
|
await tx.match.update({
|
|
where: { id: nextMatch.id },
|
|
data: { team2Players: winner },
|
|
});
|
|
}
|
|
|
|
// Update the walkover match with winnerId
|
|
await tx.match.update({
|
|
where: { id: match.id },
|
|
data: {
|
|
winnerId: winner[0], // Use first player as winner identifier
|
|
completedAt: new Date(),
|
|
status: 'COMPLETED',
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
// POST /api/tournaments/[id]/generate-bracket - Generate bracket/matches
|
|
export async function POST(
|
|
request: NextRequest,
|
|
context: RouteContext
|
|
) {
|
|
try {
|
|
const session = await getServerSession(authOptions);
|
|
|
|
if (!session?.user) {
|
|
return NextResponse.json(
|
|
{ error: 'Unauthorized' },
|
|
{ status: 401 }
|
|
);
|
|
}
|
|
|
|
// Check if user has admin role
|
|
const allowedRoles = ['SUPER_ADMIN', 'ORG_ADMIN', 'SITE_ADMIN'];
|
|
if (!allowedRoles.includes(session.user.role)) {
|
|
return NextResponse.json(
|
|
{ error: 'Forbidden: Insufficient permissions' },
|
|
{ status: 403 }
|
|
);
|
|
}
|
|
|
|
const { id } = await context.params;
|
|
|
|
// Verify tournament exists and belongs to user's organization
|
|
const tournament = await db.tournament.findFirst({
|
|
where: {
|
|
id,
|
|
organizationId: session.user.organizationId,
|
|
},
|
|
include: {
|
|
inscriptions: {
|
|
include: {
|
|
client: {
|
|
select: {
|
|
id: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
matches: true,
|
|
},
|
|
});
|
|
|
|
if (!tournament) {
|
|
return NextResponse.json(
|
|
{ error: 'Tournament not found' },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
|
|
// Check tournament status is REGISTRATION_OPEN
|
|
if (tournament.status !== 'REGISTRATION_OPEN') {
|
|
return NextResponse.json(
|
|
{ error: 'Tournament must be in REGISTRATION_OPEN status to generate bracket. Current status: ' + tournament.status },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Check minimum inscriptions (at least 2 teams)
|
|
if (tournament.inscriptions.length < 2) {
|
|
return NextResponse.json(
|
|
{ error: 'At least 2 teams must be registered to generate bracket' },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Check if bracket already exists
|
|
if (tournament.matches.length > 0) {
|
|
return NextResponse.json(
|
|
{ error: 'Bracket already exists. Delete existing matches first if you want to regenerate.' },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Get tournament format from settings
|
|
const settings = tournament.settings as { tournamentFormat?: string } | null;
|
|
const format = settings?.tournamentFormat || 'SINGLE_ELIMINATION';
|
|
|
|
// Currently only supporting single elimination
|
|
if (format !== 'SINGLE_ELIMINATION' && format !== 'BRACKET') {
|
|
return NextResponse.json(
|
|
{ error: `Bracket generation for ${format} format is not yet supported. Only SINGLE_ELIMINATION is currently available.` },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Generate bracket matches
|
|
const matchData = generateSingleEliminationBracket(
|
|
id,
|
|
tournament.inscriptions as Team[]
|
|
);
|
|
|
|
// Create matches in transaction
|
|
const result = await db.$transaction(async (tx) => {
|
|
// Create all matches
|
|
const createdMatches = [];
|
|
for (const match of matchData) {
|
|
const created = await tx.match.create({
|
|
data: match,
|
|
});
|
|
createdMatches.push({
|
|
...created,
|
|
status: match.status,
|
|
});
|
|
}
|
|
|
|
// Process walkovers and advance winners
|
|
await processWalkovers(tx, createdMatches as { id: string; round: number; position: number; status: string; team1Players: string[]; team2Players: string[] }[]);
|
|
|
|
// Update tournament status to IN_PROGRESS
|
|
await tx.tournament.update({
|
|
where: { id },
|
|
data: { status: 'IN_PROGRESS' },
|
|
});
|
|
|
|
// Fetch updated matches
|
|
return tx.match.findMany({
|
|
where: { tournamentId: id },
|
|
orderBy: [
|
|
{ round: 'asc' },
|
|
{ position: 'asc' },
|
|
],
|
|
});
|
|
});
|
|
|
|
return NextResponse.json({
|
|
message: 'Bracket generated successfully',
|
|
tournamentStatus: 'IN_PROGRESS',
|
|
matches: result,
|
|
totalMatches: result.length,
|
|
totalRounds: Math.max(...result.map(m => m.round)),
|
|
});
|
|
} catch (error) {
|
|
console.error('Error generating bracket:', error);
|
|
return NextResponse.json(
|
|
{ error: 'Failed to generate bracket' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|