import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { getSession } from "@/lib/auth-simple"; import { hasRole, canManageMatch } from "@/lib/permissions"; import { calculateTeamElo, calculateTeamEloChange } from "@/lib/elo-utils"; import { calculateOpenSkillRatings, fromOpenSkillRating } from "@/lib/openskill-utils"; import { calculateGlicko2Ratings } from "@/lib/glicko2-utils"; const K_FACTOR = 32; // Standard K-factor for Elo calculations /** * GET /api/matches * * Get matches (admin only) * Requires: club_admin, site_admin, or tournament_admin role */ export async function GET() { try { const session = await getSession(); if (!session) { return NextResponse.json( { error: "Not authenticated" }, { status: 403 } ); } const userId = session.user?.id; if (!userId) { return NextResponse.json( { error: "User ID not found in session" }, { status: 403 } ); } // Fetch user from database to get current role const user = await prisma.user.findUnique({ where: { id: userId }, select: { role: true } }); if (!user) { return NextResponse.json( { error: "User not found" }, { status: 403 } ); } const userRole = user.role as 'player' | 'tournament_admin' | 'club_admin' | 'site_admin'; let matches; if (userRole === 'club_admin' || userRole === 'site_admin') { // Club admins and site admins can see all matches matches = await prisma.match.findMany({ include: { event: { select: { id: true, name: true, }, }, player1P1: { select: { id: true, name: true } }, player1P2: { select: { id: true, name: true } }, player2P1: { select: { id: true, name: true } }, player2P2: { select: { id: true, name: true } }, }, orderBy: { playedAt: 'desc' }, }); } else { // Regular players cannot view matches via this API (should not happen if UI is protected) return NextResponse.json( { error: "Insufficient permissions to view matches" }, { status: 403 } ); } return NextResponse.json(matches); } catch (error: unknown) { console.error("Error fetching matches:", error); const message = error instanceof Error ? error.message : "Failed to fetch matches"; return NextResponse.json( { error: message }, { status: 500 } ); } } /** * POST /api/matches * * Create a casual match (not associated with a tournament) * Requires: player role or higher */ export async function POST(request: Request) { try { const body = await request.json(); const { team1P1Id, team1P2Id, team2P1Id, team2P2Id, team1Score, team2Score, playedAt, eventId, isCasual: isCasualFromRequest, } = body; // Determine if match is casual // If eventId is provided, it's a tournament match (not casual by default) // If eventId is null, it's a casual match (casual by default) const isCasual = isCasualFromRequest !== undefined ? isCasualFromRequest : (eventId ? false : true); // Validate required fields if (!team1P1Id || !team1P2Id || !team2P1Id || !team2P2Id) { return NextResponse.json( { error: "All four player IDs are required" }, { status: 400 } ); } if (team1Score === undefined || team2Score === undefined) { return NextResponse.json( { error: "Both team scores are required" }, { status: 400 } ); } // Check permissions - need at least player role const session = await getSession(); if (!session) { return NextResponse.json( { error: "Not authenticated" }, { status: 403 } ); } const userId = session.user?.id || null; // Fetch player data const [player1, player2, player3, player4] = await Promise.all([ prisma.player.findUnique({ where: { id: parseInt(team1P1Id) } }), prisma.player.findUnique({ where: { id: parseInt(team1P2Id) } }), prisma.player.findUnique({ where: { id: parseInt(team2P1Id) } }), prisma.player.findUnique({ where: { id: parseInt(team2P2Id) } }), ]); // Check all players exist const missingPlayers: number[] = []; if (!player1) missingPlayers.push(parseInt(team1P1Id)); if (!player2) missingPlayers.push(parseInt(team1P2Id)); if (!player3) missingPlayers.push(parseInt(team2P1Id)); if (!player4) missingPlayers.push(parseInt(team2P2Id)); if (missingPlayers.length > 0) { return NextResponse.json( { error: `Players not found: ${missingPlayers.join(", ")}` }, { status: 400 } ); } // Calculate match outcome const team1Won = team1Score > team2Score; const team2Won = team2Score > team1Score; const isTie = team1Score === team2Score; const team1ScoreActual = isTie ? 0.5 : (team1Won ? 1 : 0); const team2ScoreActual = isTie ? 0.5 : (team2Won ? 1 : 0); // Calculate Elo changes const team1Rating = calculateTeamElo(player1!.currentElo, player2!.currentElo); const team2Rating = calculateTeamElo(player3!.currentElo, player4!.currentElo); const team1EloChange = calculateTeamEloChange(team1Rating, team2Rating, team1ScoreActual, K_FACTOR); const team2EloChange = calculateTeamEloChange(team2Rating, team1Rating, team2ScoreActual, K_FACTOR); const player1EloChange = team1EloChange / 2; const player2EloChange = team1EloChange / 2; const player3EloChange = team2EloChange / 2; const player4EloChange = team2EloChange / 2; // Calculate OpenSkill ratings const player1OpenSkill = { mu: player1!.currentElo, sigma: 8.33 }; const player2OpenSkill = { mu: player2!.currentElo, sigma: 8.33 }; const player3OpenSkill = { mu: player3!.currentElo, sigma: 8.33 }; const player4OpenSkill = { mu: player4!.currentElo, sigma: 8.33 }; const rankings = isTie ? [0, 0] : team1Won ? [0, 1] : [1, 0]; const openSkillTeams = [[player1OpenSkill, player2OpenSkill], [player3OpenSkill, player4OpenSkill]]; const newOpenSkillRatings = calculateOpenSkillRatings(openSkillTeams, rankings); // Calculate Glicko2 ratings const Glicko2 = require('glicko2').Glicko2; const glicko = new Glicko2(); const glicko1 = glicko.makePlayer(player1!.currentElo, 350, 0.06); const glicko2 = glicko.makePlayer(player2!.currentElo, 350, 0.06); const glicko3 = glicko.makePlayer(player3!.currentElo, 350, 0.06); const glicko4 = glicko.makePlayer(player4!.currentElo, 350, 0.06); const glickoMatches: [any, any, number][] = []; const outcome = isTie ? 0.5 : team1Won ? 1 : 0; [glicko1, glicko2].forEach(p1 => { [glicko3, glicko4].forEach(p2 => { glickoMatches.push([p1, p2, outcome]); }); }); glicko.updateRatings(glickoMatches); // Create match const match = await prisma.match.create({ data: { eventId: eventId ? parseInt(eventId) : null, isCasual: isCasual, playedAt: playedAt ? new Date(playedAt) : new Date(), player1P1Id: parseInt(team1P1Id), player1P2Id: parseInt(team1P2Id), player2P1Id: parseInt(team2P1Id), player2P2Id: parseInt(team2P2Id), team1Score, team2Score, status: "completed", createdById: userId, }, }); // Update player stats (Elo) await updatePlayerStats(parseInt(team1P1Id), team1Won, player1EloChange, isTie); await updatePlayerStats(parseInt(team1P2Id), team1Won, player2EloChange, isTie); await updatePlayerStats(parseInt(team2P1Id), team2Won, player3EloChange, isTie); await updatePlayerStats(parseInt(team2P2Id), team2Won, player4EloChange, isTie); // Update OpenSkill ratings await updateOpenSkillRating(parseInt(team1P1Id), fromOpenSkillRating(newOpenSkillRatings[0][0]), team1Won, isTie); await updateOpenSkillRating(parseInt(team1P2Id), fromOpenSkillRating(newOpenSkillRatings[0][1]), team1Won, isTie); await updateOpenSkillRating(parseInt(team2P1Id), fromOpenSkillRating(newOpenSkillRatings[1][0]), team2Won, isTie); await updateOpenSkillRating(parseInt(team2P2Id), fromOpenSkillRating(newOpenSkillRatings[1][1]), team2Won, isTie); // Update Glicko2 ratings await updateGlicko2Rating(parseInt(team1P1Id), glicko1.getRating(), glicko1.getRd(), glicko1.getVol(), team1Won, isTie); await updateGlicko2Rating(parseInt(team1P2Id), glicko2.getRating(), glicko2.getRd(), glicko2.getVol(), team1Won, isTie); await updateGlicko2Rating(parseInt(team2P1Id), glicko3.getRating(), glicko3.getRd(), glicko3.getVol(), team2Won, isTie); await updateGlicko2Rating(parseInt(team2P2Id), glicko4.getRating(), glicko4.getRd(), glicko4.getVol(), team2Won, isTie); // Update partnership stats await updatePartnershipStats(parseInt(team1P1Id), parseInt(team1P2Id), team1Won, player1EloChange, isTie); await updatePartnershipStats(parseInt(team2P1Id), parseInt(team2P2Id), team2Won, player3EloChange, isTie); return NextResponse.json({ success: true, match, }); } catch (error: unknown) { console.error("Error creating casual match:", error); const message = error instanceof Error ? error.message : "Failed to create match"; return NextResponse.json( { error: message }, { status: 500 } ); } } async function updatePlayerStats(playerId: number, won: boolean, eloChange: number, isTie: boolean = false) { const player = await prisma.player.findUnique({ where: { id: playerId }, }); if (!player) { throw new Error(`Player ${playerId} not found`); } await prisma.player.update({ where: { id: playerId }, data: { currentElo: player.currentElo + Math.round(eloChange), gamesPlayed: player.gamesPlayed + 1, wins: isTie ? player.wins : (won ? player.wins + 1 : player.wins), losses: isTie ? player.losses : (won ? player.losses : player.losses + 1), }, }); } async function updatePartnershipStats(player1Id: number, player2Id: number, won: boolean, eloChange: number, isTie: boolean = false) { const [smallId, largeId] = player1Id < player2Id ? [player1Id, player2Id] : [player2Id, player1Id]; const existing = await prisma.partnershipStat.findFirst({ where: { player1Id: smallId, player2Id: largeId, }, }); const updateData = isTie ? { gamesPlayed: { increment: 1 }, totalEloChange: { increment: eloChange }, lastPlayed: new Date(), } : { gamesPlayed: { increment: 1 }, wins: won ? { increment: 1 } : undefined, losses: won ? undefined : { increment: 1 }, totalEloChange: { increment: eloChange }, lastPlayed: new Date(), }; if (existing) { await prisma.partnershipStat.update({ where: { id: existing.id }, data: updateData, }); } else { await prisma.partnershipStat.create({ data: { player1Id: smallId, player2Id: largeId, gamesPlayed: 1, wins: isTie ? 0 : (won ? 1 : 0), losses: isTie ? 0 : (won ? 0 : 1), totalEloChange: eloChange, lastPlayed: new Date(), }, }); } } async function updateOpenSkillRating(playerId: number, rating: number, won: boolean, isTie: boolean = false) { await prisma.openSkillRating.upsert({ where: { playerId }, update: { rating, gamesPlayed: { increment: 1 }, wins: won ? { increment: 1 } : undefined, losses: !won && !isTie ? { increment: 1 } : undefined, draws: isTie ? { increment: 1 } : undefined, lastUpdated: new Date(), }, create: { playerId, rating, gamesPlayed: 1, wins: won ? 1 : 0, losses: !won && !isTie ? 1 : 0, draws: isTie ? 1 : 0, lastUpdated: new Date(), }, }); } async function updateGlicko2Rating(playerId: number, rating: number, deviation: number, volatility: number, won: boolean, isTie: boolean = false) { await prisma.glicko2Rating.upsert({ where: { playerId }, update: { rating, deviation, volatility, gamesPlayed: { increment: 1 }, wins: won ? { increment: 1 } : undefined, losses: !won && !isTie ? { increment: 1 } : undefined, draws: isTie ? { increment: 1 } : undefined, lastUpdated: new Date(), }, create: { playerId, rating, deviation, volatility, gamesPlayed: 1, wins: won ? 1 : 0, losses: !won && !isTie ? 1 : 0, draws: isTie ? 1 : 0, lastUpdated: new Date(), }, }); }