feat(api): add tournaments endpoints
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>
This commit is contained in:
347
apps/web/app/api/tournaments/[id]/generate-bracket/route.ts
Normal file
347
apps/web/app/api/tournaments/[id]/generate-bracket/route.ts
Normal file
@@ -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<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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
299
apps/web/app/api/tournaments/[id]/inscriptions/route.ts
Normal file
299
apps/web/app/api/tournaments/[id]/inscriptions/route.ts
Normal file
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
380
apps/web/app/api/tournaments/[id]/matches/[matchId]/route.ts
Normal file
380
apps/web/app/api/tournaments/[id]/matches/[matchId]/route.ts
Normal file
@@ -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<typeof db, '$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends'>,
|
||||||
|
tournamentId: string,
|
||||||
|
currentRound: number,
|
||||||
|
currentPosition: number,
|
||||||
|
winnerPlayers: string[]
|
||||||
|
): Promise<void> {
|
||||||
|
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<typeof db, '$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends'>,
|
||||||
|
tournamentId: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
// 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
340
apps/web/app/api/tournaments/[id]/route.ts
Normal file
340
apps/web/app/api/tournaments/[id]/route.ts
Normal file
@@ -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<string, 'AMERICANO' | 'MEXICANO' | 'BRACKET' | 'ROUND_ROBIN' | 'LEAGUE'> = {
|
||||||
|
'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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
233
apps/web/app/api/tournaments/route.ts
Normal file
233
apps/web/app/api/tournaments/route.ts
Normal file
@@ -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<string, 'AMERICANO' | 'MEXICANO' | 'BRACKET' | 'ROUND_ROBIN' | 'LEAGUE'> = {
|
||||||
|
'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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user