diff --git a/apps/web/app/api/tournaments/[id]/generate-bracket/route.ts b/apps/web/app/api/tournaments/[id]/generate-bracket/route.ts new file mode 100644 index 0000000..6f38805 --- /dev/null +++ b/apps/web/app/api/tournaments/[id]/generate-bracket/route.ts @@ -0,0 +1,347 @@ +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, + matches: { id: string; round: number; position: number; status: string; team1Players: string[]; team2Players: string[] }[] +): Promise { + 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 } + ); + } +} diff --git a/apps/web/app/api/tournaments/[id]/inscriptions/[inscriptionId]/route.ts b/apps/web/app/api/tournaments/[id]/inscriptions/[inscriptionId]/route.ts new file mode 100644 index 0000000..5c09110 --- /dev/null +++ b/apps/web/app/api/tournaments/[id]/inscriptions/[inscriptionId]/route.ts @@ -0,0 +1,301 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { db } from '@/lib/db'; +import { z } from 'zod'; +import { Decimal } from '@prisma/client/runtime/library'; + +interface RouteContext { + params: Promise<{ id: string; inscriptionId: string }>; +} + +// Validation schema for updating an inscription +const updateInscriptionSchema = z.object({ + player1Id: z.string().cuid('Invalid player 1 ID').optional(), + player2Id: z.string().cuid('Invalid player 2 ID').optional().nullable(), + teamName: z.string().max(100).optional().nullable(), + isPaid: z.boolean().optional(), + paidAmount: z.number().nonnegative().optional(), + seedNumber: z.number().int().positive().optional(), + notes: z.string().max(500).optional().nullable(), +}); + +// PUT /api/tournaments/[id]/inscriptions/[inscriptionId] - Update inscription +export async function PUT( + request: NextRequest, + context: RouteContext +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const { id, inscriptionId } = await context.params; + + // Verify tournament exists and belongs to user's organization + const tournament = await db.tournament.findFirst({ + where: { + id, + organizationId: session.user.organizationId, + }, + }); + + if (!tournament) { + return NextResponse.json( + { error: 'Tournament not found' }, + { status: 404 } + ); + } + + // Verify inscription exists + const existingInscription = await db.tournamentInscription.findFirst({ + where: { + id: inscriptionId, + tournamentId: id, + }, + }); + + if (!existingInscription) { + return NextResponse.json( + { error: 'Inscription not found' }, + { status: 404 } + ); + } + + const body = await request.json(); + + // Validate input + const validationResult = updateInscriptionSchema.safeParse(body); + if (!validationResult.success) { + return NextResponse.json( + { + error: 'Invalid inscription data', + details: validationResult.error.flatten().fieldErrors, + }, + { status: 400 } + ); + } + + const { player1Id, player2Id, teamName, isPaid, paidAmount, seedNumber, notes } = validationResult.data; + + // Build update data + interface InscriptionUpdateData { + clientId?: string; + partnerId?: string | null; + teamName?: string | null; + isPaid?: boolean; + paidAmount?: Decimal; + seedNumber?: number; + notes?: string | null; + } + + const updateData: InscriptionUpdateData = {}; + + // Validate player 1 if changing + if (player1Id !== undefined && player1Id !== existingInscription.clientId) { + const player1 = await db.client.findFirst({ + where: { + id: player1Id, + organizationId: session.user.organizationId, + }, + }); + + if (!player1) { + return NextResponse.json( + { error: 'Player 1 not found or does not belong to your organization' }, + { status: 404 } + ); + } + + // Check player 1 is not already registered in another inscription + const existingPlayer1 = await db.tournamentInscription.findFirst({ + where: { + tournamentId: id, + id: { not: inscriptionId }, + OR: [ + { clientId: player1Id }, + { partnerId: player1Id }, + ], + }, + }); + + if (existingPlayer1) { + return NextResponse.json( + { error: 'Player 1 is already registered in another team' }, + { status: 400 } + ); + } + + updateData.clientId = player1Id; + } + + // Validate player 2 if changing + if (player2Id !== undefined) { + if (player2Id === null) { + updateData.partnerId = null; + } else if (player2Id !== existingInscription.partnerId) { + const player2 = await db.client.findFirst({ + where: { + id: player2Id, + organizationId: session.user.organizationId, + }, + }); + + if (!player2) { + return NextResponse.json( + { error: 'Player 2 not found or does not belong to your organization' }, + { status: 404 } + ); + } + + // Check player 2 is not already registered in another inscription + const existingPlayer2 = await db.tournamentInscription.findFirst({ + where: { + tournamentId: id, + id: { not: inscriptionId }, + OR: [ + { clientId: player2Id }, + { partnerId: player2Id }, + ], + }, + }); + + if (existingPlayer2) { + return NextResponse.json( + { error: 'Player 2 is already registered in another team' }, + { status: 400 } + ); + } + + updateData.partnerId = player2Id; + } + } + + if (teamName !== undefined) updateData.teamName = teamName; + if (isPaid !== undefined) updateData.isPaid = isPaid; + if (paidAmount !== undefined) updateData.paidAmount = new Decimal(paidAmount); + if (seedNumber !== undefined) updateData.seedNumber = seedNumber; + if (notes !== undefined) updateData.notes = notes; + + const inscription = await db.tournamentInscription.update({ + where: { id: inscriptionId }, + data: updateData, + include: { + client: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + phone: true, + level: true, + }, + }, + }, + }); + + // Get partner info if exists + let partner = null; + if (inscription.partnerId) { + partner = await db.client.findUnique({ + where: { id: inscription.partnerId }, + select: { + id: true, + firstName: true, + lastName: true, + email: true, + phone: true, + level: true, + }, + }); + } + + return NextResponse.json({ + ...inscription, + partner, + }); + } catch (error) { + console.error('Error updating inscription:', error); + return NextResponse.json( + { error: 'Failed to update inscription' }, + { status: 500 } + ); + } +} + +// DELETE /api/tournaments/[id]/inscriptions/[inscriptionId] - Remove inscription +export async function DELETE( + request: NextRequest, + context: RouteContext +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const { id, inscriptionId } = await context.params; + + // Verify tournament exists and belongs to user's organization + const tournament = await db.tournament.findFirst({ + where: { + id, + organizationId: session.user.organizationId, + }, + }); + + if (!tournament) { + return NextResponse.json( + { error: 'Tournament not found' }, + { status: 404 } + ); + } + + // Only allow removing inscriptions if tournament is still in registration phase + if (!['DRAFT', 'REGISTRATION_OPEN'].includes(tournament.status)) { + return NextResponse.json( + { error: 'Cannot remove inscriptions after tournament has started' }, + { status: 400 } + ); + } + + // Verify inscription exists + const existingInscription = await db.tournamentInscription.findFirst({ + where: { + id: inscriptionId, + tournamentId: id, + }, + }); + + if (!existingInscription) { + return NextResponse.json( + { error: 'Inscription not found' }, + { status: 404 } + ); + } + + // Delete inscription + await db.tournamentInscription.delete({ + where: { id: inscriptionId }, + }); + + return NextResponse.json({ + message: 'Inscription removed successfully', + }); + } catch (error) { + console.error('Error removing inscription:', error); + return NextResponse.json( + { error: 'Failed to remove inscription' }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/api/tournaments/[id]/inscriptions/route.ts b/apps/web/app/api/tournaments/[id]/inscriptions/route.ts new file mode 100644 index 0000000..419cd74 --- /dev/null +++ b/apps/web/app/api/tournaments/[id]/inscriptions/route.ts @@ -0,0 +1,299 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { db } from '@/lib/db'; +import { z } from 'zod'; + +interface RouteContext { + params: Promise<{ id: string }>; +} + +// Validation schema for creating an inscription +const createInscriptionSchema = z.object({ + player1Id: z.string().cuid('Invalid player 1 ID'), + player2Id: z.string().cuid('Invalid player 2 ID').optional(), + teamName: z.string().max(100).optional(), +}); + +// GET /api/tournaments/[id]/inscriptions - List inscriptions for tournament +export async function GET( + request: NextRequest, + context: RouteContext +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + 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, + }, + }); + + if (!tournament) { + return NextResponse.json( + { error: 'Tournament not found' }, + { status: 404 } + ); + } + + const inscriptions = await db.tournamentInscription.findMany({ + where: { + tournamentId: id, + }, + include: { + client: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + phone: true, + level: true, + }, + }, + }, + orderBy: { + registeredAt: 'asc', + }, + }); + + // Enrich inscriptions with partner info if partnerId exists + const enrichedInscriptions = await Promise.all( + inscriptions.map(async (inscription) => { + let partner = null; + if (inscription.partnerId) { + partner = await db.client.findUnique({ + where: { id: inscription.partnerId }, + select: { + id: true, + firstName: true, + lastName: true, + email: true, + phone: true, + level: true, + }, + }); + } + return { + ...inscription, + partner, + }; + }) + ); + + return NextResponse.json(enrichedInscriptions); + } catch (error) { + console.error('Error fetching inscriptions:', error); + return NextResponse.json( + { error: 'Failed to fetch inscriptions' }, + { status: 500 } + ); + } +} + +// POST /api/tournaments/[id]/inscriptions - Register team/player +export async function POST( + request: NextRequest, + context: RouteContext +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + 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: { + _count: { + select: { + inscriptions: true, + }, + }, + }, + }); + + if (!tournament) { + return NextResponse.json( + { error: 'Tournament not found' }, + { status: 404 } + ); + } + + // Check tournament status is OPEN (REGISTRATION_OPEN) + if (tournament.status !== 'REGISTRATION_OPEN') { + return NextResponse.json( + { error: 'Tournament is not open for registration' }, + { status: 400 } + ); + } + + // Check maxTeams not exceeded + if (tournament.maxPlayers && tournament._count.inscriptions >= tournament.maxPlayers) { + return NextResponse.json( + { error: 'Tournament has reached maximum number of teams' }, + { status: 400 } + ); + } + + const body = await request.json(); + + // Validate input + const validationResult = createInscriptionSchema.safeParse(body); + if (!validationResult.success) { + return NextResponse.json( + { + error: 'Invalid inscription data', + details: validationResult.error.flatten().fieldErrors, + }, + { status: 400 } + ); + } + + const { player1Id, player2Id, teamName } = validationResult.data; + + // Verify player 1 exists and belongs to user's organization + const player1 = await db.client.findFirst({ + where: { + id: player1Id, + organizationId: session.user.organizationId, + }, + }); + + if (!player1) { + return NextResponse.json( + { error: 'Player 1 not found or does not belong to your organization' }, + { status: 404 } + ); + } + + // Check player 1 is not already registered + const existingInscription = await db.tournamentInscription.findFirst({ + where: { + tournamentId: id, + OR: [ + { clientId: player1Id }, + { partnerId: player1Id }, + ], + }, + }); + + if (existingInscription) { + return NextResponse.json( + { error: 'Player 1 is already registered for this tournament' }, + { status: 400 } + ); + } + + // Verify player 2 if provided + if (player2Id) { + const player2 = await db.client.findFirst({ + where: { + id: player2Id, + organizationId: session.user.organizationId, + }, + }); + + if (!player2) { + return NextResponse.json( + { error: 'Player 2 not found or does not belong to your organization' }, + { status: 404 } + ); + } + + // Check player 2 is not already registered + const existingInscription2 = await db.tournamentInscription.findFirst({ + where: { + tournamentId: id, + OR: [ + { clientId: player2Id }, + { partnerId: player2Id }, + ], + }, + }); + + if (existingInscription2) { + return NextResponse.json( + { error: 'Player 2 is already registered for this tournament' }, + { status: 400 } + ); + } + } + + // Create inscription + const inscription = await db.tournamentInscription.create({ + data: { + tournamentId: id, + clientId: player1Id, + partnerId: player2Id || null, + teamName: teamName || null, + seedNumber: tournament._count.inscriptions + 1, + isPaid: false, + paidAmount: 0, + }, + include: { + client: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + phone: true, + level: true, + }, + }, + }, + }); + + // Get partner info if exists + let partner = null; + if (player2Id) { + partner = await db.client.findUnique({ + where: { id: player2Id }, + select: { + id: true, + firstName: true, + lastName: true, + email: true, + phone: true, + level: true, + }, + }); + } + + return NextResponse.json( + { + ...inscription, + partner, + }, + { status: 201 } + ); + } catch (error) { + console.error('Error creating inscription:', error); + return NextResponse.json( + { error: 'Failed to create inscription' }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/api/tournaments/[id]/matches/[matchId]/route.ts b/apps/web/app/api/tournaments/[id]/matches/[matchId]/route.ts new file mode 100644 index 0000000..adc17a8 --- /dev/null +++ b/apps/web/app/api/tournaments/[id]/matches/[matchId]/route.ts @@ -0,0 +1,380 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { db } from '@/lib/db'; +import { z } from 'zod'; + +interface RouteContext { + params: Promise<{ id: string; matchId: string }>; +} + +// Validation schema for updating match result +const updateMatchSchema = z.object({ + score1: z.array(z.number().int().min(0)).optional(), + score2: z.array(z.number().int().min(0)).optional(), + winnerId: z.string().cuid('Invalid winner ID').optional(), + status: z.enum(['SCHEDULED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED', 'WALKOVER']).optional(), + courtId: z.string().cuid('Invalid court ID').optional().nullable(), + scheduledAt: z.string().datetime().optional().nullable(), + notes: z.string().max(500).optional().nullable(), +}); + +// Advance winner to next round +async function advanceWinner( + tx: Omit, + tournamentId: string, + currentRound: number, + currentPosition: number, + winnerPlayers: string[] +): Promise { + const nextRound = currentRound + 1; + const nextPosition = Math.ceil(currentPosition / 2); + + // Find the next match + const nextMatch = await tx.match.findFirst({ + where: { + tournamentId, + round: nextRound, + position: nextPosition, + }, + }); + + if (!nextMatch) { + // No next match means this was the final + return; + } + + // Determine if this match feeds into team1 or team2 of next match + // Odd positions go to team1, even positions go to team2 + const isUpperBracket = currentPosition % 2 === 1; + + if (isUpperBracket) { + await tx.match.update({ + where: { id: nextMatch.id }, + data: { team1Players: winnerPlayers }, + }); + } else { + await tx.match.update({ + where: { id: nextMatch.id }, + data: { team2Players: winnerPlayers }, + }); + } +} + +// Check if tournament is complete +async function checkTournamentComplete( + tx: Omit, + tournamentId: string +): Promise { + // Find the final match (highest round) + const finalMatch = await tx.match.findFirst({ + where: { tournamentId }, + orderBy: { round: 'desc' }, + }); + + if (!finalMatch) return false; + + // Check if final match is completed + return finalMatch.status === 'COMPLETED'; +} + +// PUT /api/tournaments/[id]/matches/[matchId] - Update match result +export async function PUT( + request: NextRequest, + context: RouteContext +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const { id, matchId } = await context.params; + + // Verify tournament exists and belongs to user's organization + const tournament = await db.tournament.findFirst({ + where: { + id, + organizationId: session.user.organizationId, + }, + }); + + if (!tournament) { + return NextResponse.json( + { error: 'Tournament not found' }, + { status: 404 } + ); + } + + // Check tournament is in progress + if (tournament.status !== 'IN_PROGRESS') { + return NextResponse.json( + { error: 'Tournament is not in progress' }, + { status: 400 } + ); + } + + // Verify match exists + const existingMatch = await db.match.findFirst({ + where: { + id: matchId, + tournamentId: id, + }, + }); + + if (!existingMatch) { + return NextResponse.json( + { error: 'Match not found' }, + { status: 404 } + ); + } + + // Can't update already completed matches + if (existingMatch.status === 'COMPLETED') { + return NextResponse.json( + { error: 'Cannot update a completed match' }, + { status: 400 } + ); + } + + const body = await request.json(); + + // Validate input + const validationResult = updateMatchSchema.safeParse(body); + if (!validationResult.success) { + return NextResponse.json( + { + error: 'Invalid match data', + details: validationResult.error.flatten().fieldErrors, + }, + { status: 400 } + ); + } + + const { score1, score2, winnerId, status, courtId, scheduledAt, notes } = validationResult.data; + + // Validate winner is one of the teams + if (winnerId && status === 'COMPLETED') { + const team1HasWinner = existingMatch.team1Players.includes(winnerId); + const team2HasWinner = existingMatch.team2Players.includes(winnerId); + + if (!team1HasWinner && !team2HasWinner) { + return NextResponse.json( + { error: 'Winner must be a player from one of the teams' }, + { status: 400 } + ); + } + } + + // Validate court if provided + if (courtId) { + const court = await db.court.findFirst({ + where: { + id: courtId, + site: { + organizationId: session.user.organizationId, + }, + }, + }); + + if (!court) { + return NextResponse.json( + { error: 'Court not found or does not belong to your organization' }, + { status: 404 } + ); + } + } + + // Build update data + interface MatchUpdateData { + team1Score?: number[]; + team2Score?: number[]; + winnerId?: string | null; + status?: 'SCHEDULED' | 'IN_PROGRESS' | 'COMPLETED' | 'CANCELLED' | 'WALKOVER'; + courtId?: string | null; + scheduledAt?: Date | null; + startedAt?: Date | null; + completedAt?: Date | null; + notes?: string | null; + } + + const updateData: MatchUpdateData = {}; + + if (score1 !== undefined) updateData.team1Score = score1; + if (score2 !== undefined) updateData.team2Score = score2; + if (winnerId !== undefined) updateData.winnerId = winnerId; + if (status !== undefined) { + updateData.status = status; + + // Set timestamps based on status + if (status === 'IN_PROGRESS' && existingMatch.status === 'SCHEDULED') { + updateData.startedAt = new Date(); + } else if (status === 'COMPLETED') { + updateData.completedAt = new Date(); + } + } + if (courtId !== undefined) updateData.courtId = courtId; + if (scheduledAt !== undefined) updateData.scheduledAt = scheduledAt ? new Date(scheduledAt) : null; + if (notes !== undefined) updateData.notes = notes; + + // Use transaction to update match and advance winner + const result = await db.$transaction(async (tx) => { + // Update the match + const updatedMatch = await tx.match.update({ + where: { id: matchId }, + data: updateData, + include: { + court: { + select: { + id: true, + name: true, + }, + }, + }, + }); + + // If match is completed with a winner, advance to next round + if (status === 'COMPLETED' && winnerId) { + const winnerTeam = existingMatch.team1Players.includes(winnerId) + ? existingMatch.team1Players + : existingMatch.team2Players; + + await advanceWinner( + tx, + id, + existingMatch.round, + existingMatch.position, + winnerTeam + ); + + // Check if tournament is complete + const isComplete = await checkTournamentComplete(tx, id); + if (isComplete) { + await tx.tournament.update({ + where: { id }, + data: { status: 'COMPLETED' }, + }); + } + } + + return updatedMatch; + }); + + // Fetch tournament status after update + const updatedTournament = await db.tournament.findUnique({ + where: { id }, + select: { status: true }, + }); + + return NextResponse.json({ + ...result, + tournamentStatus: updatedTournament?.status, + }); + } catch (error) { + console.error('Error updating match:', error); + return NextResponse.json( + { error: 'Failed to update match' }, + { status: 500 } + ); + } +} + +// GET /api/tournaments/[id]/matches/[matchId] - Get match details +export async function GET( + request: NextRequest, + context: RouteContext +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const { id, matchId } = await context.params; + + // Verify tournament exists and belongs to user's organization + const tournament = await db.tournament.findFirst({ + where: { + id, + organizationId: session.user.organizationId, + }, + }); + + if (!tournament) { + return NextResponse.json( + { error: 'Tournament not found' }, + { status: 404 } + ); + } + + const match = await db.match.findFirst({ + where: { + id: matchId, + tournamentId: id, + }, + include: { + court: { + select: { + id: true, + name: true, + type: true, + }, + }, + booking: { + select: { + id: true, + startTime: true, + endTime: true, + status: true, + }, + }, + }, + }); + + if (!match) { + return NextResponse.json( + { error: 'Match not found' }, + { status: 404 } + ); + } + + // Enrich with player details + const allPlayerIds = [...match.team1Players, ...match.team2Players]; + const players = await db.client.findMany({ + where: { + id: { in: allPlayerIds }, + }, + select: { + id: true, + firstName: true, + lastName: true, + level: true, + }, + }); + + const playerMap = new Map(players.map(p => [p.id, p])); + + const team1 = match.team1Players.map(id => playerMap.get(id) || { id, firstName: 'Unknown', lastName: '', level: null }); + const team2 = match.team2Players.map(id => playerMap.get(id) || { id, firstName: 'Unknown', lastName: '', level: null }); + + return NextResponse.json({ + ...match, + team1Details: team1, + team2Details: team2, + }); + } catch (error) { + console.error('Error fetching match:', error); + return NextResponse.json( + { error: 'Failed to fetch match' }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/api/tournaments/[id]/route.ts b/apps/web/app/api/tournaments/[id]/route.ts new file mode 100644 index 0000000..a05fa7e --- /dev/null +++ b/apps/web/app/api/tournaments/[id]/route.ts @@ -0,0 +1,340 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { db } from '@/lib/db'; +import { z } from 'zod'; +import { Decimal } from '@prisma/client/runtime/library'; +import { Prisma } from '@prisma/client'; + +interface RouteContext { + params: Promise<{ id: string }>; +} + +// Validation schema for updating a tournament +const updateTournamentSchema = z.object({ + name: z.string().min(1).max(100).optional(), + description: z.string().max(500).optional().nullable(), + date: z.string().datetime().optional(), + endDate: z.string().datetime().optional().nullable(), + type: z.enum(['SINGLE_ELIMINATION', 'DOUBLE_ELIMINATION', 'ROUND_ROBIN', 'LEAGUE']).optional(), + category: z.string().max(50).optional().nullable(), + maxTeams: z.number().int().min(2).optional(), + price: z.number().nonnegative().optional(), + status: z.enum(['DRAFT', 'REGISTRATION_OPEN', 'REGISTRATION_CLOSED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED']).optional(), + rules: z.string().max(2000).optional().nullable(), + isPublic: z.boolean().optional(), +}); + +// Map API types to database types +function mapTournamentType(type: string): 'AMERICANO' | 'MEXICANO' | 'BRACKET' | 'ROUND_ROBIN' | 'LEAGUE' { + const mapping: Record = { + 'SINGLE_ELIMINATION': 'BRACKET', + 'DOUBLE_ELIMINATION': 'BRACKET', + 'ROUND_ROBIN': 'ROUND_ROBIN', + 'LEAGUE': 'LEAGUE', + }; + return mapping[type] || 'BRACKET'; +} + +// GET /api/tournaments/[id] - Get tournament with inscriptions count and matches +export async function GET( + request: NextRequest, + context: RouteContext +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const { id } = await context.params; + + const tournament = await db.tournament.findFirst({ + where: { + id, + organizationId: session.user.organizationId, + }, + include: { + site: { + select: { + id: true, + name: true, + slug: true, + address: true, + phone: true, + email: true, + }, + }, + inscriptions: { + include: { + client: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + phone: true, + level: true, + }, + }, + }, + orderBy: { + registeredAt: 'asc', + }, + }, + matches: { + include: { + court: { + select: { + id: true, + name: true, + }, + }, + }, + orderBy: [ + { round: 'asc' }, + { position: 'asc' }, + ], + }, + _count: { + select: { + inscriptions: true, + matches: true, + }, + }, + }, + }); + + if (!tournament) { + return NextResponse.json( + { error: 'Tournament not found' }, + { status: 404 } + ); + } + + return NextResponse.json(tournament); + } catch (error) { + console.error('Error fetching tournament:', error); + return NextResponse.json( + { error: 'Failed to fetch tournament' }, + { status: 500 } + ); + } +} + +// PUT /api/tournaments/[id] - Update tournament details +export async function PUT( + 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 existingTournament = await db.tournament.findFirst({ + where: { + id, + organizationId: session.user.organizationId, + }, + }); + + if (!existingTournament) { + return NextResponse.json( + { error: 'Tournament not found' }, + { status: 404 } + ); + } + + // If user is SITE_ADMIN, verify they have access to this site + if (session.user.role === 'SITE_ADMIN' && existingTournament.siteId && session.user.siteId !== existingTournament.siteId) { + return NextResponse.json( + { error: 'Forbidden: You do not have access to this tournament' }, + { status: 403 } + ); + } + + const body = await request.json(); + + // Validate input + const validationResult = updateTournamentSchema.safeParse(body); + if (!validationResult.success) { + return NextResponse.json( + { + error: 'Invalid tournament data', + details: validationResult.error.flatten().fieldErrors, + }, + { status: 400 } + ); + } + + const { name, description, date, endDate, type, category, maxTeams, price, status, rules, isPublic } = validationResult.data; + + // Build update data + const updateData: Prisma.TournamentUpdateInput = {}; + + if (name !== undefined) updateData.name = name; + if (description !== undefined) updateData.description = description; + if (date !== undefined) updateData.startDate = new Date(date); + if (endDate !== undefined) updateData.endDate = endDate ? new Date(endDate) : null; + + // Build settings update + let newSettings = existingTournament.settings as Prisma.JsonObject | null; + + if (type !== undefined) { + updateData.type = mapTournamentType(type); + // Also update settings with the format + newSettings = { + ...(newSettings || {}), + tournamentFormat: type, + }; + } + if (category !== undefined) { + newSettings = { + ...(newSettings || {}), + category, + }; + } + + if (newSettings && (type !== undefined || category !== undefined)) { + updateData.settings = newSettings; + } + + if (maxTeams !== undefined) updateData.maxPlayers = maxTeams; + if (price !== undefined) updateData.entryFee = new Decimal(price); + if (status !== undefined) updateData.status = status; + if (rules !== undefined) updateData.rules = rules; + if (isPublic !== undefined) updateData.isPublic = isPublic; + + const tournament = await db.tournament.update({ + where: { id }, + data: updateData, + include: { + site: { + select: { + id: true, + name: true, + slug: true, + }, + }, + _count: { + select: { + inscriptions: true, + matches: true, + }, + }, + }, + }); + + return NextResponse.json(tournament); + } catch (error) { + console.error('Error updating tournament:', error); + return NextResponse.json( + { error: 'Failed to update tournament' }, + { status: 500 } + ); + } +} + +// DELETE /api/tournaments/[id] - Delete tournament (only if DRAFT status) +export async function DELETE( + 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 existingTournament = await db.tournament.findFirst({ + where: { + id, + organizationId: session.user.organizationId, + }, + include: { + _count: { + select: { + inscriptions: true, + matches: true, + }, + }, + }, + }); + + if (!existingTournament) { + return NextResponse.json( + { error: 'Tournament not found' }, + { status: 404 } + ); + } + + // If user is SITE_ADMIN, verify they have access to this site + if (session.user.role === 'SITE_ADMIN' && existingTournament.siteId && session.user.siteId !== existingTournament.siteId) { + return NextResponse.json( + { error: 'Forbidden: You do not have access to this tournament' }, + { status: 403 } + ); + } + + // Only allow deletion of DRAFT tournaments + if (existingTournament.status !== 'DRAFT') { + return NextResponse.json( + { error: 'Only tournaments in DRAFT status can be deleted. Consider cancelling instead.' }, + { status: 400 } + ); + } + + // Delete the tournament (cascades to inscriptions and matches) + await db.tournament.delete({ + where: { id }, + }); + + return NextResponse.json({ + message: 'Tournament deleted successfully', + }); + } catch (error) { + console.error('Error deleting tournament:', error); + return NextResponse.json( + { error: 'Failed to delete tournament' }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/api/tournaments/route.ts b/apps/web/app/api/tournaments/route.ts new file mode 100644 index 0000000..f28cc42 --- /dev/null +++ b/apps/web/app/api/tournaments/route.ts @@ -0,0 +1,233 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { db } from '@/lib/db'; +import { z } from 'zod'; +import { Decimal } from '@prisma/client/runtime/library'; + +// Validation schema for creating a tournament +const createTournamentSchema = z.object({ + siteId: z.string().cuid('Invalid site ID'), + name: z.string().min(1, 'Name is required').max(100), + description: z.string().max(500).optional(), + date: z.string().datetime({ message: 'Invalid date format' }), + endDate: z.string().datetime({ message: 'Invalid end date format' }).optional(), + type: z.enum(['SINGLE_ELIMINATION', 'DOUBLE_ELIMINATION', 'ROUND_ROBIN', 'LEAGUE']), + category: z.string().max(50).optional(), + maxTeams: z.number().int().min(2, 'Minimum 2 teams required'), + price: z.number().nonnegative('Price must be non-negative'), +}); + +// Map API types to database types +function mapTournamentType(type: string): 'AMERICANO' | 'MEXICANO' | 'BRACKET' | 'ROUND_ROBIN' | 'LEAGUE' { + const mapping: Record = { + 'SINGLE_ELIMINATION': 'BRACKET', + 'DOUBLE_ELIMINATION': 'BRACKET', + 'ROUND_ROBIN': 'ROUND_ROBIN', + 'LEAGUE': 'LEAGUE', + }; + return mapping[type] || 'BRACKET'; +} + +// GET /api/tournaments - List tournaments with filters +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const { searchParams } = new URL(request.url); + const siteId = searchParams.get('siteId'); + const status = searchParams.get('status'); + const startDate = searchParams.get('startDate'); + const endDate = searchParams.get('endDate'); + + // Build the where clause + interface TournamentWhereClause { + organizationId: string; + siteId?: string; + status?: 'DRAFT' | 'REGISTRATION_OPEN' | 'REGISTRATION_CLOSED' | 'IN_PROGRESS' | 'COMPLETED' | 'CANCELLED'; + startDate?: { + gte?: Date; + lte?: Date; + }; + } + + const whereClause: TournamentWhereClause = { + organizationId: session.user.organizationId, + }; + + // Filter by site + if (siteId) { + whereClause.siteId = siteId; + } else if (session.user.siteId) { + whereClause.siteId = session.user.siteId; + } + + // Filter by status + if (status) { + const validStatuses = ['DRAFT', 'REGISTRATION_OPEN', 'REGISTRATION_CLOSED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED'] as const; + const upperStatus = status.toUpperCase(); + if (validStatuses.includes(upperStatus as typeof validStatuses[number])) { + whereClause.status = upperStatus as typeof validStatuses[number]; + } + } + + // Filter by date range + if (startDate || endDate) { + whereClause.startDate = {}; + + if (startDate) { + const start = new Date(startDate); + start.setHours(0, 0, 0, 0); + whereClause.startDate.gte = start; + } + + if (endDate) { + const end = new Date(endDate); + end.setHours(23, 59, 59, 999); + whereClause.startDate.lte = end; + } + } + + const tournaments = await db.tournament.findMany({ + where: whereClause, + include: { + site: { + select: { + id: true, + name: true, + slug: true, + }, + }, + _count: { + select: { + inscriptions: true, + matches: true, + }, + }, + }, + orderBy: { + startDate: 'desc', + }, + }); + + return NextResponse.json(tournaments); + } catch (error) { + console.error('Error fetching tournaments:', error); + return NextResponse.json( + { error: 'Failed to fetch tournaments' }, + { status: 500 } + ); + } +} + +// POST /api/tournaments - Create a new tournament +export async function POST(request: NextRequest) { + 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 body = await request.json(); + + // Validate input with Zod schema + const validationResult = createTournamentSchema.safeParse(body); + if (!validationResult.success) { + return NextResponse.json( + { + error: 'Invalid tournament data', + details: validationResult.error.flatten().fieldErrors, + }, + { status: 400 } + ); + } + + const { siteId, name, description, date, endDate, type, category, maxTeams, price } = validationResult.data; + + // Verify site belongs to user's organization + const site = await db.site.findFirst({ + where: { + id: siteId, + organizationId: session.user.organizationId, + }, + }); + + if (!site) { + return NextResponse.json( + { error: 'Site not found or does not belong to your organization' }, + { status: 404 } + ); + } + + // If user is SITE_ADMIN, verify they have access to this site + if (session.user.role === 'SITE_ADMIN' && session.user.siteId !== siteId) { + return NextResponse.json( + { error: 'Forbidden: You do not have access to this site' }, + { status: 403 } + ); + } + + // Create tournament + const tournament = await db.tournament.create({ + data: { + organizationId: session.user.organizationId, + siteId, + name, + description: description || null, + type: mapTournamentType(type), + status: 'DRAFT', + startDate: new Date(date), + endDate: endDate ? new Date(endDate) : null, + maxPlayers: maxTeams, + entryFee: new Decimal(price), + settings: { + tournamentFormat: type, + category: category || null, + }, + }, + include: { + site: { + select: { + id: true, + name: true, + slug: true, + }, + }, + _count: { + select: { + inscriptions: true, + matches: true, + }, + }, + }, + }); + + return NextResponse.json(tournament, { status: 201 }); + } catch (error) { + console.error('Error creating tournament:', error); + return NextResponse.json( + { error: 'Failed to create tournament' }, + { status: 500 } + ); + } +}