From 3a78f354ad5d4a8ecf691100b1ca7f91363c1a14 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Mon, 30 Mar 2026 21:28:09 -0700 Subject: [PATCH] feat: add recalculateAllElo function for idempotent ELO recalculation --- prisma/schema.prisma | 110 +++++----- src/__tests__/unit/recalculate-elo.test.ts | 190 +++++++++++++++++ src/app/admin/page.tsx | 2 + src/app/api/admin/recalculate-elo/route.ts | 47 +++++ src/components/RecalculateEloButton.tsx | 61 ++++++ src/lib/elo-utils.ts | 231 +++++++++++++++++++++ src/lib/permissions.ts | 14 ++ 7 files changed, 594 insertions(+), 61 deletions(-) create mode 100644 src/__tests__/unit/recalculate-elo.test.ts create mode 100644 src/app/api/admin/recalculate-elo/route.ts create mode 100644 src/components/RecalculateEloButton.tsx diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 29fd59e..a07bfc7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,67 +1,55 @@ - generator client { provider = "prisma-client-js" } datasource db { - provider = "sqlite" + provider = "postgresql" url = env("DATABASE_URL") } -// Note: For PostgreSQL, create a separate schema file or use environment variable -// See scripts/switch-database.js for provider switching logic - model Player { id Int @id @default(autoincrement()) name String - rating Int @default(0) // Historical rating (used for rankings) - currentElo Int @default(1000) // Current Elo rating - gamesPlayed Int @default(0) // Total games played - wins Int @default(0) // Total wins - losses Int @default(0) // Total losses + rating Int @default(0) + currentElo Int @default(1000) + gamesPlayed Int @default(0) + wins Int @default(0) + losses Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + normalizedName String @unique eloSnapshots EloSnapshot[] eventParticipants EventParticipant[] - matchesAsP4 Match[] @relation("MatchPlayer4") - matchesAsP3 Match[] @relation("MatchPlayer3") - matchesAsP2 Match[] @relation("MatchPlayer2") matchesAsP1 Match[] @relation("MatchPlayer1") - partnershipGames2 PartnershipGame[] @relation("PartnershipPlayer2") + matchesAsP2 Match[] @relation("MatchPlayer2") + matchesAsP3 Match[] @relation("MatchPlayer3") + matchesAsP4 Match[] @relation("MatchPlayer4") partnershipGames PartnershipGame[] @relation("PartnershipPlayer1") - partnershipStats2 PartnershipStat[] @relation("StatPlayer2") + partnershipGames2 PartnershipGame[] @relation("PartnershipPlayer2") partnershipStats PartnershipStat[] @relation("StatPlayer1") - teamsAsPlayer2 Team[] @relation("TeamPlayer2") + partnershipStats2 PartnershipStat[] @relation("StatPlayer2") teamsAsPlayer1 Team[] @relation("TeamPlayer1") + teamsAsPlayer2 Team[] @relation("TeamPlayer2") user User? @@map("players") } model User { - id String @id @default(cuid()) - email String @unique - emailVerified Boolean @default(false) - name String? - image String? - role String @default("player") // player, tournament_admin, club_admin - - // Session and account references for Better Auth - sessions Session[] - accounts Account[] - - // Link to EuchreCamp player (optional) - playerId Int? @unique - player Player? @relation(fields: [playerId], references: [id]) - - // Tournaments owned by this user - ownedTournaments Event[] @relation("TournamentOwner") - - // Matches created by this user - createdMatches Match[] @relation("MatchCreator") - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + email String @unique + emailVerified Boolean @default(false) + name String? + image String? + role String @default("player") + playerId Int? @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + accounts Account[] + ownedTournaments Event[] @relation("TournamentOwner") + createdMatches Match[] @relation("MatchCreator") + sessions Session[] + player Player? @relation(fields: [playerId], references: [id]) @@map("users") } @@ -76,15 +64,15 @@ model Event { format String @default("round_robin") status String @default("planned") maxParticipants Int? - ownerId String? // User ID of the tournament owner (for tournament_admin) + ownerId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt bracketMatchups BracketMatchup[] participants EventParticipant[] + owner User? @relation("TournamentOwner", fields: [ownerId], references: [id]) matches Match[] teams Team[] rounds TournamentRound[] - owner User? @relation("TournamentOwner", fields: [ownerId], references: [id]) @@map("events") } @@ -99,9 +87,9 @@ model EventParticipant { registrationDate DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - team Team? @relation(fields: [teamId], references: [id]) - player Player @relation(fields: [playerId], references: [id]) event Event @relation(fields: [eventId], references: [id]) + player Player @relation(fields: [playerId], references: [id]) + team Team? @relation(fields: [teamId], references: [id]) @@unique([eventId, playerId]) @@map("event_participants") @@ -115,12 +103,12 @@ model Team { player2Id Int createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - bracketMatchups2 BracketMatchup[] @relation("BracketTeam2") bracketMatchups1 BracketMatchup[] @relation("BracketTeam1") + bracketMatchups2 BracketMatchup[] @relation("BracketTeam2") eventParticipants EventParticipant[] - player2 Player @relation("TeamPlayer2", fields: [player2Id], references: [id]) - player1 Player @relation("TeamPlayer1", fields: [player1Id], references: [id]) event Event @relation(fields: [eventId], references: [id]) + player1 Player @relation("TeamPlayer1", fields: [player1Id], references: [id]) + player2 Player @relation("TeamPlayer2", fields: [player2Id], references: [id]) @@map("teams") } @@ -155,11 +143,11 @@ model BracketMatchup { status String @default("pending") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - match Match? @relation(fields: [matchId], references: [id]) - team2 Team? @relation("BracketTeam2", fields: [team2Id], references: [id]) - team1 Team? @relation("BracketTeam1", fields: [team1Id], references: [id]) event Event @relation(fields: [eventId], references: [id]) + match Match? @relation(fields: [matchId], references: [id]) round TournamentRound @relation(fields: [roundId], references: [id]) + team1 Team? @relation("BracketTeam1", fields: [team1Id], references: [id]) + team2 Team? @relation("BracketTeam2", fields: [team2Id], references: [id]) @@map("bracket_matchups") } @@ -175,18 +163,18 @@ model Match { team1Score Int team2Score Int status String @default("completed") - createdById String? // User ID of the match creator (for tournament_admin) + createdById String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt bracketMatchups BracketMatchup[] eloSnapshots EloSnapshot[] - team2P2 Player @relation("MatchPlayer4", fields: [team2P2Id], references: [id]) - team2P1 Player @relation("MatchPlayer3", fields: [team2P1Id], references: [id]) - team1P2 Player @relation("MatchPlayer2", fields: [team1P2Id], references: [id]) - team1P1 Player @relation("MatchPlayer1", fields: [team1P1Id], references: [id]) - event Event? @relation(fields: [eventId], references: [id]) - partnershipGames PartnershipGame[] createdBy User? @relation("MatchCreator", fields: [createdById], references: [id]) + event Event? @relation(fields: [eventId], references: [id]) + team1P1 Player @relation("MatchPlayer1", fields: [team1P1Id], references: [id]) + team1P2 Player @relation("MatchPlayer2", fields: [team1P2Id], references: [id]) + team2P1 Player @relation("MatchPlayer3", fields: [team2P1Id], references: [id]) + team2P2 Player @relation("MatchPlayer4", fields: [team2P2Id], references: [id]) + partnershipGames PartnershipGame[] @@map("matches") } @@ -216,8 +204,8 @@ model PartnershipGame { player2EloChange Int? createdAt DateTime @default(now()) match Match @relation(fields: [matchId], references: [id]) - player2 Player @relation("PartnershipPlayer2", fields: [player2Id], references: [id]) player1 Player @relation("PartnershipPlayer1", fields: [player1Id], references: [id]) + player2 Player @relation("PartnershipPlayer2", fields: [player2Id], references: [id]) @@map("partnership_games") } @@ -233,16 +221,17 @@ model PartnershipStat { lastPlayed DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - player2 Player @relation("StatPlayer2", fields: [player2Id], references: [id]) player1 Player @relation("StatPlayer1", fields: [player1Id], references: [id]) + player2 Player @relation("StatPlayer2", fields: [player2Id], references: [id]) + @@unique([player1Id, player2Id], name: "partnership_stats_player1Id_player2Id_key") @@map("partnership_stats") } model Session { id String @id expiresAt DateTime - token String + token String @unique createdAt DateTime @default(now()) updatedAt DateTime @updatedAt ipAddress String? @@ -250,7 +239,6 @@ model Session { userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) - @@unique([token]) @@index([userId]) @@map("session") } @@ -260,7 +248,6 @@ model Account { accountId String providerId String userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) accessToken String? refreshToken String? idToken String? @@ -270,6 +257,7 @@ model Account { password String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) @@map("account") diff --git a/src/__tests__/unit/recalculate-elo.test.ts b/src/__tests__/unit/recalculate-elo.test.ts new file mode 100644 index 0000000..f7cd8cd --- /dev/null +++ b/src/__tests__/unit/recalculate-elo.test.ts @@ -0,0 +1,190 @@ +/** + * Unit Tests: ELO Recalculation + * + * Tests the idempotent full recalculation of ELO ratings and player statistics + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, test, expect, beforeEach, vi } from 'vitest'; +import { recalculateAllElo } from '@/lib/elo-utils'; + +// Mock Prisma client +const mockPrisma = { + player: { + updateMany: vi.fn().mockResolvedValue({ count: 0 }), + update: vi.fn().mockResolvedValue({}), + }, + eloSnapshot: { + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + create: vi.fn().mockResolvedValue({}), + }, + partnershipStat: { + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + findFirst: vi.fn().mockResolvedValue(null), // No existing stats initially + update: vi.fn().mockResolvedValue({}), + create: vi.fn().mockResolvedValue({}), + }, + match: { + findMany: vi.fn().mockResolvedValue([]), + }, +}; + +describe('recalculateAllElo', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('should reset all player stats to zero', async () => { + (mockPrisma.match.findMany as any).mockResolvedValue([]); + + const result = await recalculateAllElo(mockPrisma as any); + + expect(mockPrisma.player.updateMany).toHaveBeenCalledWith({ + data: { + currentElo: 1000, + gamesPlayed: 0, + wins: 0, + losses: 0, + }, + }); + + expect(result.matchesProcessed).toBe(0); + expect(result.playersUpdated).toBe(0); + expect(result.partnershipsUpdated).toBe(0); + }); + + test('should delete all existing elo snapshots', async () => { + (mockPrisma.match.findMany as any).mockResolvedValue([]); + + await recalculateAllElo(mockPrisma as any); + + expect(mockPrisma.eloSnapshot.deleteMany).toHaveBeenCalledWith({}); + }); + + test('should delete all existing partnership stats', async () => { + (mockPrisma.match.findMany as any).mockResolvedValue([]); + + await recalculateAllElo(mockPrisma as any); + + expect(mockPrisma.partnershipStat.deleteMany).toHaveBeenCalledWith({}); + }); + + test('should return correct result for empty match list', async () => { + (mockPrisma.match.findMany as any).mockResolvedValue([]); + + const result = await recalculateAllElo(mockPrisma as any); + + expect(result).toEqual({ + matchesProcessed: 0, + playersUpdated: 0, + partnershipsUpdated: 0, + }); + }); + + test('should process matches in chronological order', async () => { + const mockMatches = [ + { + id: 1, + playedAt: new Date('2024-01-01'), + team1P1: { id: 1, name: 'Player 1' }, + team1P2: { id: 2, name: 'Player 2' }, + team2P1: { id: 3, name: 'Player 3' }, + team2P2: { id: 4, name: 'Player 4' }, + team1Score: 10, + team2Score: 5, + }, + { + id: 2, + playedAt: new Date('2024-01-02'), + team1P1: { id: 1, name: 'Player 1' }, + team1P2: { id: 2, name: 'Player 2' }, + team2P1: { id: 5, name: 'Player 5' }, + team2P2: { id: 6, name: 'Player 6' }, + team1Score: 8, + team2Score: 6, + }, + ]; + + (mockPrisma.match.findMany as any).mockResolvedValue(mockMatches); + + await recalculateAllElo(mockPrisma as any); + + // Should process 2 matches + expect(mockPrisma.match.findMany).toHaveBeenCalledWith({ + orderBy: { playedAt: 'asc' }, + include: { + team1P1: true, + team1P2: true, + team2P1: true, + team2P2: true, + }, + }); + }); + + test('should create elo snapshots for each player in each match', async () => { + const mockMatches = [ + { + id: 1, + playedAt: new Date('2024-01-01'), + team1P1: { id: 1, name: 'Player 1' }, + team1P2: { id: 2, name: 'Player 2' }, + team2P1: { id: 3, name: 'Player 3' }, + team2P2: { id: 4, name: 'Player 4' }, + team1Score: 10, + team2Score: 5, + }, + ]; + + (mockPrisma.match.findMany as any).mockResolvedValue(mockMatches); + + await recalculateAllElo(mockPrisma as any); + + // Should create 4 snapshots (one for each player) + expect(mockPrisma.eloSnapshot.create).toHaveBeenCalledTimes(4); + }); + + test('should update player stats after processing matches', async () => { + const mockMatches = [ + { + id: 1, + playedAt: new Date('2024-01-01'), + team1P1: { id: 1, name: 'Player 1' }, + team1P2: { id: 2, name: 'Player 2' }, + team2P1: { id: 3, name: 'Player 3' }, + team2P2: { id: 4, name: 'Player 4' }, + team1Score: 10, + team2Score: 5, + }, + ]; + + (mockPrisma.match.findMany as any).mockResolvedValue(mockMatches); + + await recalculateAllElo(mockPrisma as any); + + // Should update 4 players + expect(mockPrisma.player.update).toHaveBeenCalledTimes(4); + }); + + test('should update partnership stats after processing matches', async () => { + const mockMatches = [ + { + id: 1, + playedAt: new Date('2024-01-01'), + team1P1: { id: 1, name: 'Player 1' }, + team1P2: { id: 2, name: 'Player 2' }, + team2P1: { id: 3, name: 'Player 3' }, + team2P2: { id: 4, name: 'Player 4' }, + team1Score: 10, + team2Score: 5, + }, + ]; + + (mockPrisma.match.findMany as any).mockResolvedValue(mockMatches); + + await recalculateAllElo(mockPrisma as any); + + // Should check for existing partnership stats (2 calls) and create new ones (2 calls) + expect(mockPrisma.partnershipStat.findFirst).toHaveBeenCalledTimes(2); + expect(mockPrisma.partnershipStat.create).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 7e2b6b0..8dfc6d3 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -4,6 +4,7 @@ import Link from "next/link" import { redirect } from "next/navigation" import { getSession } from "@/lib/auth-simple" import type { Event as EventModel } from "@prisma/client" +import { RecalculateEloButton } from "../../components/RecalculateEloButton" export default async function AdminDashboard() { console.log('AdminDashboard rendering...') @@ -169,6 +170,7 @@ export default async function AdminDashboard() { All Tournaments + diff --git a/src/app/api/admin/recalculate-elo/route.ts b/src/app/api/admin/recalculate-elo/route.ts new file mode 100644 index 0000000..fda1633 --- /dev/null +++ b/src/app/api/admin/recalculate-elo/route.ts @@ -0,0 +1,47 @@ +import { NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { hasRole } from '@/lib/permissions'; +import { recalculateAllElo } from '@/lib/elo-utils'; + +/** + * POST /api/admin/recalculate-elo + * + * Triggers a full recalculation of all ELO ratings and player statistics. + * This endpoint is idempotent - running it multiple times produces the same result. + * + * Requires: club_admin role + */ +export async function POST() { + try { + // Check if user has club_admin role + const permission = await hasRole('club_admin'); + if (!permission.allowed) { + return NextResponse.json( + { error: permission.reason || 'Unauthorized' }, + { status: 403 } + ); + } + + // Perform the recalculation + const result = await recalculateAllElo(prisma); + + return NextResponse.json({ + success: true, + message: 'ELO recalculation completed successfully', + data: result, + }); + } catch (error) { + console.error('Error during ELO recalculation:', error); + return NextResponse.json( + { error: 'Failed to recalculate ELO ratings' }, + { status: 500 } + ); + } +} + +export async function GET() { + return NextResponse.json( + { error: 'Method not allowed. Use POST to trigger recalculation.' }, + { status: 405 } + ); +} diff --git a/src/components/RecalculateEloButton.tsx b/src/components/RecalculateEloButton.tsx new file mode 100644 index 0000000..5dbbf74 --- /dev/null +++ b/src/components/RecalculateEloButton.tsx @@ -0,0 +1,61 @@ +"use client" + +import { useState } from "react" + +export function RecalculateEloButton() { + const [isLoading, setIsLoading] = useState(false) + + const handleClick = async () => { + if (!confirm('Are you sure you want to recalculate all ELO ratings? This will reset all player stats and recalculate them from match history. This operation is idempotent and safe to run multiple times.')) { + return + } + + setIsLoading(true) + try { + const response = await fetch('/api/admin/recalculate-elo', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }) + const data = await response.json() + + if (data.success) { + alert(`Recalculation completed: ${JSON.stringify(data.data)}`) + window.location.reload() + } else { + alert(`Error: ${data.error}`) + } + } catch (err) { + alert(`Error: ${err instanceof Error ? err.message : 'Unknown error occurred'}`) + } finally { + setIsLoading(false) + } + } + + return ( + + ) +} diff --git a/src/lib/elo-utils.ts b/src/lib/elo-utils.ts index 561e73e..9607503 100644 --- a/src/lib/elo-utils.ts +++ b/src/lib/elo-utils.ts @@ -1,3 +1,5 @@ +import type { PrismaClient } from '@prisma/client'; + /** * Elo Rating Utilities * @@ -150,3 +152,232 @@ export function calculateTeamEloChange( ): number { return calculateEloChange(team1Rating, team2Rating, team1Score, kFactor); } + +/** + * Recalculate all player and partnership statistics from match history + * This function is idempotent - running it multiple times produces the same result + * @param prisma Prisma client instance + * @returns Object with counts of affected records + */ +export async function recalculateAllElo(prisma: PrismaClient) { + // Get all matches in chronological order + const matches = await prisma.match.findMany({ + orderBy: { playedAt: 'asc' }, + include: { + team1P1: true, + team1P2: true, + team2P1: true, + team2P2: true, + }, + }); + + // Reset all player stats to zero + await prisma.player.updateMany({ + data: { + currentElo: 1000, + gamesPlayed: 0, + wins: 0, + losses: 0, + }, + }); + + // Delete all existing elo snapshots + await prisma.eloSnapshot.deleteMany({}); + + // Delete all existing partnership stats (will be rebuilt) + await prisma.partnershipStat.deleteMany({}); + + // Initialize in-memory tracking for all players + const playerStats = new Map(); + + // Initialize partnership tracking + const partnershipStats = new Map(); + + // Helper to get/create player stats + const getPlayerStats = (playerId: number) => { + if (!playerStats.has(playerId)) { + playerStats.set(playerId, { rating: 1000, gamesPlayed: 0, wins: 0, losses: 0 }); + } + return playerStats.get(playerId)!; + }; + + // Helper to get partnership key (sorted player IDs) + const getPartnershipKey = (p1Id: number, p2Id: number) => { + return [Math.min(p1Id, p2Id), Math.max(p1Id, p2Id)].join('-'); + }; + + // Helper to get/update partnership stats + const getPartnershipStats = (p1Id: number, p2Id: number) => { + const key = getPartnershipKey(p1Id, p2Id); + if (!partnershipStats.has(key)) { + partnershipStats.set(key, { gamesPlayed: 0, wins: 0, losses: 0, totalEloChange: 0, lastPlayed: null }); + } + return partnershipStats.get(key)!; + }; + + // Process each match in chronological order + for (const match of matches) { + const { team1P1, team1P2, team2P1, team2P2, team1Score, team2Score, id: matchId, playedAt } = match; + + // Get current ratings for all players + const p1Rating = getPlayerStats(team1P1.id).rating; + const p2Rating = getPlayerStats(team1P2.id).rating; + const p3Rating = getPlayerStats(team2P1.id).rating; + const p4Rating = getPlayerStats(team2P2.id).rating; + + // Calculate team ratings + const team1Rating = calculateTeamElo(p1Rating, p2Rating); + const team2Rating = calculateTeamElo(p3Rating, p4Rating); + + // Determine match result (team1 score based on points) + const team1ScoreNormalized = team1Score > team2Score ? 1 : team1Score === team2Score ? 0.5 : 0; + + // Calculate team ELO changes + const team1Change = calculateTeamEloChange(team1Rating, team2Rating, team1ScoreNormalized); + const team2Change = -team1Change; // Equal and opposite + + // Split changes evenly between partners + const p1Change = Math.round(team1Change / 2); + const p2Change = Math.round(team1Change / 2); + const p3Change = Math.round(team2Change / 2); + const p4Change = Math.round(team2Change / 2); + + // Update player stats + const team1Won = team1Score > team2Score; + const team2Won = team2Score > team1Score; + + // Update Player 1 (team 1, player 1) + const stats1 = getPlayerStats(team1P1.id); + stats1.rating += p1Change; + stats1.gamesPlayed += 1; + stats1.wins += team1Won ? 1 : 0; + stats1.losses += team1Won ? 0 : 1; + + // Update Player 2 (team 1, player 2) + const stats2 = getPlayerStats(team1P2.id); + stats2.rating += p2Change; + stats2.gamesPlayed += 1; + stats2.wins += team1Won ? 1 : 0; + stats2.losses += team1Won ? 0 : 1; + + // Update Player 3 (team 2, player 1) + const stats3 = getPlayerStats(team2P1.id); + stats3.rating += p3Change; + stats3.gamesPlayed += 1; + stats3.wins += team2Won ? 1 : 0; + stats3.losses += team2Won ? 0 : 1; + + // Update Player 4 (team 2, player 2) + const stats4 = getPlayerStats(team2P2.id); + stats4.rating += p4Change; + stats4.gamesPlayed += 1; + stats4.wins += team2Won ? 1 : 0; + stats4.losses += team2Won ? 0 : 1; + + // Update partnership stats for team 1 + const partnership1 = getPartnershipStats(team1P1.id, team1P2.id); + partnership1.gamesPlayed += 1; + partnership1.wins += team1Won ? 1 : 0; + partnership1.losses += team1Won ? 0 : 1; + partnership1.totalEloChange += p1Change + p2Change; + if (playedAt && (!partnership1.lastPlayed || playedAt > partnership1.lastPlayed)) { + partnership1.lastPlayed = playedAt; + } + + // Update partnership stats for team 2 + const partnership2 = getPartnershipStats(team2P1.id, team2P2.id); + partnership2.gamesPlayed += 1; + partnership2.wins += team2Won ? 1 : 0; + partnership2.losses += team2Won ? 0 : 1; + partnership2.totalEloChange += p3Change + p4Change; + if (playedAt && (!partnership2.lastPlayed || playedAt > partnership2.lastPlayed)) { + partnership2.lastPlayed = playedAt; + } + + // Create elo snapshots for all players + const snapshotData = [ + { playerId: team1P1.id, ratingBefore: p1Rating, ratingChange: p1Change }, + { playerId: team1P2.id, ratingBefore: p2Rating, ratingChange: p2Change }, + { playerId: team2P1.id, ratingBefore: p3Rating, ratingChange: p3Change }, + { playerId: team2P2.id, ratingBefore: p4Rating, ratingChange: p4Change }, + ]; + + for (const snapshot of snapshotData) { + await prisma.eloSnapshot.create({ + data: { + playerId: snapshot.playerId, + matchId: matchId, + ratingBefore: snapshot.ratingBefore, + ratingAfter: snapshot.ratingBefore + snapshot.ratingChange, + ratingChange: snapshot.ratingChange, + }, + }); + } + } + + // Write all updated player stats to database + const playerUpdatePromises = Array.from(playerStats.entries()).map( + async ([playerId, stats]) => + await prisma.player.update({ + where: { id: playerId }, + data: { + currentElo: stats.rating, + gamesPlayed: stats.gamesPlayed, + wins: stats.wins, + losses: stats.losses, + }, + }) + ); + + await Promise.all(playerUpdatePromises); + + // Write all partnership stats to database + const partnershipUpdatePromises = Array.from(partnershipStats.entries()).map(async ([key, stats]) => { + const [player1Id, player2Id] = key.split('-').map(Number); + + // Check if the partnership stat already exists + const existingStat = await prisma.partnershipStat.findFirst({ + where: { + player1Id, + player2Id, + }, + }); + + if (existingStat) { + // Update existing record + return await prisma.partnershipStat.update({ + where: { + id: existingStat.id, + }, + data: { + gamesPlayed: stats.gamesPlayed, + wins: stats.wins, + losses: stats.losses, + totalEloChange: stats.totalEloChange, + lastPlayed: stats.lastPlayed, + }, + }); + } else { + // Create new record + return await prisma.partnershipStat.create({ + data: { + player1Id, + player2Id, + gamesPlayed: stats.gamesPlayed, + wins: stats.wins, + losses: stats.losses, + totalEloChange: stats.totalEloChange, + lastPlayed: stats.lastPlayed, + }, + }); + } + }); + + await Promise.all(partnershipUpdatePromises); + + return { + matchesProcessed: matches.length, + playersUpdated: playerStats.size, + partnershipsUpdated: partnershipStats.size, + }; +} diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts index 5485c53..e265a19 100644 --- a/src/lib/permissions.ts +++ b/src/lib/permissions.ts @@ -215,3 +215,17 @@ export async function getManageableTournaments() { orderBy: { createdAt: 'desc' } }); } + +/** + * Check if the user can edit user profiles (only club_admin) + */ +export async function canEditUserProfiles(): Promise { + return hasRole('club_admin'); +} + +/** + * Check if the user can recalculate ELO ratings (only club_admin) + */ +export async function canRecalculateElo(): Promise { + return hasRole('club_admin'); +}