diff --git a/src/app/api/auth/[[...all]]/route.ts b/src/app/api/auth/[[...all]]/route.ts new file mode 100644 index 0000000..5b67b06 --- /dev/null +++ b/src/app/api/auth/[[...all]]/route.ts @@ -0,0 +1,4 @@ +import { auth } from "@/lib/auth"; +import { toNextJsHandler } from "better-auth/next-js"; + +export const { GET, POST } = toNextJsHandler(auth); diff --git a/src/app/api/matches/upload/route.ts b/src/app/api/matches/upload/route.ts index 9503e81..87b793b 100644 --- a/src/app/api/matches/upload/route.ts +++ b/src/app/api/matches/upload/route.ts @@ -1,244 +1,258 @@ -import { NextRequest, NextResponse } from "next/server" -import { prisma } from "@/lib/prisma" -import { auth } from "@/lib/auth" +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import Papa from "papaparse"; +import { canManageTournament } from "@/lib/permissions"; +import { getSession } from "@/lib/auth-simple"; +import { calculateTeamElo, calculateExpectedTeamScore, calculateTeamEloChange } from "@/lib/elo-utils"; -// eslint-disable-next-line @typescript-eslint/no-var-requires -const Papa = require("papaparse") - -// Map table names to numeric IDs -const TABLE_NAME_MAP: Record = { - "Clubs": 1, - "Hearts": 2, - "Diamonds": 3, - "Spades": 4, - "Stars": 5, +interface CSVRow { + "Event #": string; + Round: string; + Table: string; + "Seat 1": string; + "Seat 3": string; + "Odds Points": string; + "Seat 2": string; + "Seat 4": string; + "Evens Points": string; + Winner?: string; } -export async function POST(request: NextRequest) { - const session = await auth() - - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - } +const K_FACTOR = 32; // Standard K-factor for Elo calculations +export async function POST(request: Request) { try { - const formData = await request.formData() - const file = formData.get("csvFile") as File - const eventId = formData.get("eventId") as string + const formData = await request.formData(); + const csvFile = formData.get("csvFile") as File; + const eventId = formData.get("eventId") as string; - if (!file) { - return NextResponse.json({ error: "No file uploaded" }, { status: 400 }) + if (!csvFile) { + return NextResponse.json( + { error: "No CSV file provided" }, + { status: 400 } + ); } - const fileBuffer = Buffer.from(await file.arrayBuffer()) - const fileText = fileBuffer.toString() + if (!eventId) { + return NextResponse.json( + { error: "No tournament selected" }, + { status: 400 } + ); + } - const parseResult = Papa.parse(fileText, { + // Check if user has permission to add matches to this tournament + const permission = await canManageTournament(parseInt(eventId)); + if (!permission.allowed) { + return NextResponse.json( + { error: permission.reason || 'Insufficient permissions to add matches' }, + { status: 403 } + ); + } + + // Get the current user to assign as match creator + const session = await getSession(); + const userId = session?.user?.id || null; + + // Read and parse CSV + const csvText = await csvFile.text(); + const parseResult = Papa.parse(csvText, { header: true, skipEmptyLines: true, - }) + }); if (parseResult.errors.length > 0) { return NextResponse.json( - { error: "CSV parsing error", details: parseResult.errors }, + { error: "CSV parsing errors", errors: parseResult.errors.map((e: any) => e.message) }, { status: 400 } - ) + ); } - const results = parseResult.data as Record[] + const rows = parseResult.data; + let importedCount = 0; + let errorCount = 0; + const errors: string[] = []; - let importedCount = 0 - let errorCount = 0 - const errors: string[] = [] - - for (const row of results) { + // Process each row + for (const [index, row] of rows.entries()) { try { - // Extract and validate data - const roundNumber = parseInt(row.Round) - const tableName = row.Table - const seat1Name = row["Seat 1"]?.trim() - const seat3Name = row["Seat 3"]?.trim() - const oddsPoints = parseInt(row["Odds Points"]) - const seat2Name = row["Seat 2"]?.trim() - const seat4Name = row["Seat 4"]?.trim() - const evensPoints = parseInt(row["Evens Points"]) + // Extract player names + const player1Name = row["Seat 1"]?.trim(); + const player2Name = row["Seat 3"]?.trim(); + const player3Name = row["Seat 2"]?.trim(); + const player4Name = row["Seat 4"]?.trim(); - // Validate required fields - if (!seat1Name || !seat3Name || !seat2Name || !seat4Name) { - errors.push(`Row ${importedCount + errorCount + 2}: Missing player names`) - errorCount++ - continue + if (!player1Name || !player2Name || !player3Name || !player4Name) { + errors.push(`Row ${index + 2}: Missing player names`); + errorCount++; + continue; } - // Find or create players - const players = await Promise.all( - [seat1Name, seat3Name, seat2Name, seat4Name].map(async (name) => { - let player = await prisma.player.findUnique({ where: { name } }) - if (!player) { - player = await prisma.player.create({ data: { name } }) - } - return player - }) - ) + // Find or create players + const players = await Promise.all([ + findOrCreatePlayer(player1Name), + findOrCreatePlayer(player2Name), + findOrCreatePlayer(player3Name), + findOrCreatePlayer(player4Name), + ]); + + console.log('Players found/created:'); + players.forEach((p, i) => { + console.log(` Player ${i + 1}: ${p.name} (ID: ${p.id}, Elo: ${p.currentElo})`); + }); - const [player1, player2, player3, player4] = players - - // Map table name to number - const tableNumber = TABLE_NAME_MAP[tableName] || 0 - - // Find or create round - let round = await prisma.tournamentRound.findFirst({ - where: { eventId: parseInt(eventId), roundNumber }, - }) - - if (!round) { - round = await prisma.tournamentRound.create({ - data: { - eventId: parseInt(eventId), - roundNumber, - status: "completed", - }, - }) - } + // Parse scores + const team1Score = parseInt(row["Odds Points"]) || 0; + const team2Score = parseInt(row["Evens Points"]) || 0; // Determine winner - const team1Won = oddsPoints > evensPoints + const team1Won = team1Score > team2Score; + const team2Won = team2Score > team1Score; + const isTie = team1Score === team2Score; + + // Calculate Elo ratings using standard formula + const team1Rating = calculateTeamElo(players[0].currentElo, players[1].currentElo); + const team2Rating = calculateTeamElo(players[2].currentElo, players[3].currentElo); + + // Actual scores (1 for win, 0.5 for tie, 0 for loss) + const team1ScoreActual = isTie ? 0.5 : (team1Won ? 1 : 0); + const team2ScoreActual = isTie ? 0.5 : (team2Won ? 1 : 0); + + // Elo change for each team + const team1EloChange = calculateTeamEloChange(team1Rating, team2Rating, team1ScoreActual, K_FACTOR); + const team2EloChange = calculateTeamEloChange(team2Rating, team1Rating, team2ScoreActual, K_FACTOR); + + // Individual Elo changes (split evenly between team members) + const player1EloChange = team1EloChange / 2; + const player2EloChange = team1EloChange / 2; + const player3EloChange = team2EloChange / 2; + const player4EloChange = team2EloChange / 2; // Create match - const match = await prisma.match.create({ + await prisma.match.create({ data: { eventId: parseInt(eventId), - team1P1Id: player1.id, - team1P2Id: player2.id, - team2P1Id: player3.id, - team2P2Id: player4.id, - team1Score: oddsPoints, - team2Score: evensPoints, playedAt: new Date(), + team1P1Id: players[0].id, + team1P2Id: players[1].id, + team2P1Id: players[2].id, + team2P2Id: players[3].id, + team1Score, + team2Score, status: "completed", + createdById: userId, }, - }) + }); - // Calculate Elo for all players - const playersForElo = [ - { id: player1.id, isTeam1: true }, - { id: player2.id, isTeam1: true }, - { id: player3.id, isTeam1: false }, - { id: player4.id, isTeam1: false }, - ] + // Update individual player stats + await updatePlayerStats(players[0].id, team1Won, player1EloChange); + await updatePlayerStats(players[1].id, team1Won, player2EloChange); + await updatePlayerStats(players[2].id, team2Won, player3EloChange); + await updatePlayerStats(players[3].id, team2Won, player4EloChange); - const K = 32 + // Update partnership stats (for tracking how players perform together) + await updatePartnershipStats(players[0].id, players[1].id, team1Won, player1EloChange); + await updatePartnershipStats(players[2].id, players[3].id, team2Won, player3EloChange); - for (const p of playersForElo) { - const playerData = await prisma.player.findUnique({ - where: { id: p.id }, - }) - - if (!playerData) continue - - const expectedScore = 1 / (1 + Math.pow(10, (1200 - playerData.currentElo) / 400)) - const actualScore = p.isTeam1 ? (team1Won ? 1 : 0) : (team1Won ? 0 : 1) - - const ratingChange = Math.round(K * (actualScore - expectedScore)) - const newRating = playerData.currentElo + ratingChange - - await prisma.player.update({ - where: { id: p.id }, - data: { currentElo: newRating }, - }) - - await prisma.eloSnapshot.create({ - data: { - playerId: p.id, - matchId: match.id, - ratingBefore: playerData.currentElo, - ratingAfter: newRating, - ratingChange, - }, - }) - } - - // Track partnership games - await prisma.partnershipGame.create({ - data: { - player1Id: player1.id, - player2Id: player2.id, - matchId: match.id, - teamNumber: 1, - wonMatch: team1Won ? 1 : 0, - }, - }) - - await prisma.partnershipGame.create({ - data: { - player1Id: player3.id, - player2Id: player4.id, - matchId: match.id, - teamNumber: 2, - wonMatch: team1Won ? 0 : 1, - }, - }) - - // Update partnership statistics - const partnerships = [ - [player1.id, player2.id], - [player3.id, player4.id], - ] - - for (const [p1Id, p2Id] of partnerships) { - const [player1Id, player2Id] = p1Id < p2Id ? [p1Id, p2Id] : [p2Id, p1Id] - - const existingStat = await prisma.partnershipStat.findFirst({ - where: { player1Id, player2Id }, - }) - - const won = - (player1Id === player1.id && player2Id === player2.id && team1Won) || - (player1Id === player3.id && player2Id === player4.id && !team1Won) - - if (existingStat) { - await prisma.partnershipStat.update({ - where: { id: existingStat.id }, - data: { - gamesPlayed: { increment: 1 }, - wins: { increment: won ? 1 : 0 }, - losses: { increment: won ? 0 : 1 }, - lastPlayed: new Date(), - }, - }) - } else { - await prisma.partnershipStat.create({ - data: { - player1Id, - player2Id, - gamesPlayed: 1, - wins: won ? 1 : 0, - losses: won ? 0 : 1, - totalEloChange: 0, - lastPlayed: new Date(), - }, - }) - } - } - - importedCount++ + importedCount++; } catch (err: any) { - errors.push(`Row ${importedCount + errorCount + 2}: ${err.message}`) - errorCount++ + errors.push(`Row ${index + 2}: ${err.message}`); + errorCount++; } } return NextResponse.json({ - success: true, importedCount, errorCount, - errors: errorCount > 0 ? errors : undefined, - }) + errors: errors.length > 0 ? errors : undefined, + }); } catch (error: any) { - console.error("Error uploading CSV:", error) + console.error("Upload error:", error); return NextResponse.json( - { error: "Failed to process CSV file", details: error.message }, + { error: error.message || "Failed to upload CSV" }, { status: 500 } - ) + ); + } +} + +async function findOrCreatePlayer(name: string) { + // Try to find existing player with exact name match + let player = await prisma.player.findFirst({ + where: { name }, + }); + + if (!player) { + // Create a new player with a unique name + const uniqueName = `${name}-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`; + player = await prisma.player.create({ + data: { + name: uniqueName, + rating: 1000, + currentElo: 1000, + }, + }); + } + + return player; +} + +async function updatePlayerStats(playerId: number, won: boolean, eloChange: number) { + // First, get the current player data to calculate the new values + const player = await prisma.player.findUnique({ + where: { id: playerId }, + }); + + if (!player) { + throw new Error(`Player ${playerId} not found`); + } + + // Update the player's statistics + await prisma.player.update({ + where: { id: playerId }, + data: { + currentElo: player.currentElo + Math.round(eloChange), + gamesPlayed: player.gamesPlayed + 1, + wins: won ? player.wins + 1 : player.wins, + losses: won ? player.losses : player.losses + 1, + }, + }); +} + +async function updatePartnershipStats(player1Id: number, player2Id: number, won: boolean, eloChange: number) { + // Sort IDs to ensure consistent partnership lookup + const [smallId, largeId] = player1Id < player2Id ? [player1Id, player2Id] : [player2Id, player1Id]; + + // Find existing partnership + const existing = await prisma.partnershipStat.findFirst({ + where: { + player1Id: smallId, + player2Id: largeId, + }, + }); + + if (existing) { + // Update existing partnership + await prisma.partnershipStat.update({ + where: { id: existing.id }, + data: { + gamesPlayed: { increment: 1 }, + wins: won ? { increment: 1 } : undefined, + losses: won ? undefined : { increment: 1 }, + totalEloChange: { increment: eloChange }, + lastPlayed: new Date(), + }, + }); + } else { + // Create new partnership + await prisma.partnershipStat.create({ + data: { + player1Id: smallId, + player2Id: largeId, + gamesPlayed: 1, + wins: won ? 1 : 0, + losses: won ? 0 : 1, + totalEloChange: eloChange, + lastPlayed: new Date(), + }, + }); } } diff --git a/src/app/api/tournaments/route.ts b/src/app/api/tournaments/route.ts index df14fdc..9e8d4d0 100644 --- a/src/app/api/tournaments/route.ts +++ b/src/app/api/tournaments/route.ts @@ -1,49 +1,14 @@ -import { NextRequest, NextResponse } from "next/server" -import { prisma } from "@/lib/prisma" -import { auth } from "@/lib/auth" - -export async function POST(request: NextRequest) { - const session = await auth() - - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - } - - try { - const body = await request.json() - const { name, description, eventDate, format, maxParticipants } = body - - if (!name) { - return NextResponse.json( - { error: "Tournament name is required" }, - { status: 400 } - ) - } - - const tournament = await prisma.event.create({ - data: { - name, - description: description || "", - eventDate: eventDate ? new Date(eventDate) : null, - format: format || "round_robin", - status: "planned", - maxParticipants: maxParticipants || null, - }, - }) - - return NextResponse.json({ success: true, tournament }) - } catch (error) { - console.error("Error creating tournament:", error) - return NextResponse.json( - { error: "Failed to create tournament" }, - { status: 500 } - ) - } -} +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { getTournamentStatus } from "@/lib/tournamentUtils"; +import { canCreateTournaments } from "@/lib/permissions"; +import { getSession } from "@/lib/auth-simple"; export async function GET() { try { const tournaments = await prisma.event.findMany({ + where: { eventType: "tournament" }, + orderBy: { createdAt: "desc" }, include: { participants: true, teams: { @@ -52,17 +17,77 @@ export async function GET() { player2: true, }, }, - rounds: true, }, - orderBy: { createdAt: "desc" }, - }) + }); - return NextResponse.json({ tournaments }) + // Update tournament statuses based on event date + const updatedTournaments = await Promise.all( + tournaments.map(async (tournament) => { + const calculatedStatus = getTournamentStatus(tournament.eventDate); + + // Only update if the calculated status differs from the stored status + if (tournament.status !== calculatedStatus) { + await prisma.event.update({ + where: { id: tournament.id }, + data: { status: calculatedStatus }, + }); + return { ...tournament, status: calculatedStatus }; + } + + return tournament; + }) + ); + + return NextResponse.json({ tournaments: updatedTournaments }); } catch (error) { - console.error("Error fetching tournaments:", error) + console.error("Failed to fetch tournaments:", error); return NextResponse.json( { error: "Failed to fetch tournaments" }, { status: 500 } - ) + ); + } +} + +export async function POST(request: Request) { + try { + // Check if user has permission to create tournaments + const permission = await canCreateTournaments(); + if (!permission.allowed) { + return NextResponse.json( + { error: permission.reason || 'Insufficient permissions to create tournaments' }, + { status: 403 } + ); + } + + // Get the current user to assign as owner + const session = await getSession(); + if (!session?.user?.id) { + return NextResponse.json( + { error: 'User not authenticated' }, + { status: 401 } + ); + } + + const body = await request.json(); + const { name, format, eventDate } = body; + + const tournament = await prisma.event.create({ + data: { + name, + format: format || "round_robin", + eventDate: eventDate ? new Date(eventDate) : null, + eventType: "tournament", + status: "planned", + ownerId: session.user.id, // Assign ownership to the creator + }, + }); + + return NextResponse.json({ tournament }, { status: 201 }); + } catch (error) { + console.error("Failed to create tournament:", error); + return NextResponse.json( + { error: "Failed to create tournament" }, + { status: 500 } + ); } } diff --git a/src/app/api/users/[id]/role/route.ts b/src/app/api/users/[id]/role/route.ts new file mode 100644 index 0000000..8dd19fc --- /dev/null +++ b/src/app/api/users/[id]/role/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { getSession } from '@/lib/auth-simple'; +import { canAssignTournamentAdmin } from '@/lib/permissions'; + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await getSession(); + + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Users can only query their own role or if they're an admin + // Fetch the requesting user's current role from database to avoid session cache issues + const requestingUser = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { role: true } + }); + + if (!requestingUser) { + return NextResponse.json({ error: 'Requesting user not found' }, { status: 404 }); + } + + if (session.user.id !== params.id && requestingUser.role !== 'club_admin') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const user = await prisma.user.findUnique({ + where: { id: params.id }, + select: { role: true } + }); + + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + return NextResponse.json({ role: user.role }); + } catch (error) { + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function PATCH( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + // Check if the current user has permission to assign roles + const permission = await canAssignTournamentAdmin(); + if (!permission.allowed) { + return NextResponse.json( + { error: permission.reason || 'Insufficient permissions' }, + { status: 403 } + ); + } + + const { role } = await request.json(); + + // Validate the role + const validRoles = ['player', 'tournament_admin', 'club_admin']; + if (!validRoles.includes(role)) { + return NextResponse.json( + { error: `Invalid role. Must be one of: ${validRoles.join(', ')}` }, + { status: 400 } + ); + } + + // Update the user's role + const updatedUser = await prisma.user.update({ + where: { id: params.id }, + data: { role } + }); + + return NextResponse.json({ + message: 'Role updated successfully', + user: { id: updatedUser.id, email: updatedUser.email, role: updatedUser.role } + }); + } catch (error) { + console.error("Failed to update user role:", error); + return NextResponse.json( + { error: "Failed to update user role" }, + { status: 500 } + ); + } +}