diff --git a/src/app/admin/players/page.tsx b/src/app/admin/players/page.tsx index 39c7997..f9e4d1e 100644 --- a/src/app/admin/players/page.tsx +++ b/src/app/admin/players/page.tsx @@ -22,6 +22,11 @@ export default function AdminPlayersPage() { const [editingPlayer, setEditingPlayer] = useState(null) const [newName, setNewName] = useState("") const [isSaving, setIsSaving] = useState(false) + + // Merge player state + const [mergeMode, setMergeMode] = useState(false) + const [selectedPlayers, setSelectedPlayers] = useState([]) + const [isMerging, setIsMerging] = useState(false) useEffect(() => { fetchPlayers() @@ -76,6 +81,47 @@ export default function AdminPlayersPage() { } } + const togglePlayerSelection = (playerId: number) => { + setSelectedPlayers(prev => + prev.includes(playerId) + ? prev.filter(id => id !== playerId) + : [...prev, playerId] + ) + } + + const handleMerge = async () => { + if (selectedPlayers.length < 2) return + + setIsMerging(true) + try { + const response = await fetch('/api/admin/players/merge', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + playerIds: selectedPlayers, + }), + }) + + const data = await response.json() + + if (data.success) { + alert(`Successfully merged ${selectedPlayers.length} players! Elo ratings have been recalculated.`) + // Refresh the player list + fetchPlayers() + setSelectedPlayers([]) + setMergeMode(false) + } else { + alert(`Error: ${data.error}`) + } + } catch (err: unknown) { + alert(`Error: ${err instanceof Error ? err.message : 'Unknown error occurred'}`) + } finally { + setIsMerging(false) + } + } + const playerCount = players.length return ( @@ -92,8 +138,37 @@ export default function AdminPlayersPage() { Manage {playerCount} player{playerCount !== 1 ? 's' : ''} in the system

+
+ +
+ {mergeMode && selectedPlayers.length > 0 && ( +
+
+ + {selectedPlayers.length} player{selectedPlayers.length !== 1 ? 's' : ''} selected for merging + + +
+
+ )} + {error && (
{error} @@ -129,63 +204,85 @@ export default function AdminPlayersPage() { - {players.map((player) => ( - - -
-
- - {player.name.charAt(0).toUpperCase()} - -
-
-
- {player.name} + {players.map((player) => { + const isSelected = selectedPlayers.includes(player.id) + return ( + mergeMode && togglePlayerSelection(player.id)} + > + +
+ {mergeMode && ( + togglePlayerSelection(player.id)} + className="mr-3 h-4 w-4 text-purple-600 focus:ring-purple-500 border-gray-300 rounded" + /> + )} +
+ + {player.name.charAt(0).toUpperCase()} + +
+
+
+ {player.name} +
-
- - - {player.currentElo} - - - {player.gamesPlayed} - - - {player.wins}-{player.losses} - - - {player.gamesPlayed > 0 - ? ((player.wins / player.gamesPlayed) * 100).toFixed(1) + '%' - : 'N/A'} - - - {player.user ? ( - - {player.user.email} - - ) : ( - No account - )} - - -
- - View - - -
- - - ))} + + + {player.currentElo} + + + {player.gamesPlayed} + + + {player.wins}-{player.losses} + + + {player.gamesPlayed > 0 + ? ((player.wins / player.gamesPlayed) * 100).toFixed(1) + '%' + : 'N/A'} + + + {player.user ? ( + + {player.user.email} + + ) : ( + No account + )} + + +
+ + View + + {!mergeMode && ( + + )} +
+ + + ) + })}
diff --git a/src/app/api/admin/players/merge/route.ts b/src/app/api/admin/players/merge/route.ts new file mode 100644 index 0000000..b798e25 --- /dev/null +++ b/src/app/api/admin/players/merge/route.ts @@ -0,0 +1,263 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { hasRole } from "@/lib/permissions"; +import { recalculateAllElo } from "@/lib/elo-utils"; + +/** + * POST /api/admin/players/merge + * + * Merge multiple players into a single player + * Requires: club_admin or site_admin role + * + * Body: + * { + * playerIds: number[] // IDs of players to merge (first one becomes canonical) + * } + * + * Notes: + * - First player ID in the array becomes the canonical player + * - All other players will be merged into the canonical player + * - After merging, all Elo ratings are recalculated from scratch + */ +export async function POST(request: Request) { + try { + // Check permissions + const permission = await hasRole('club_admin'); + if (!permission.allowed) { + return NextResponse.json( + { error: permission.reason || "Not authorized to merge players" }, + { status: 403 } + ); + } + + const body = await request.json(); + const { playerIds } = body; + + // Validate input + if (!playerIds || !Array.isArray(playerIds) || playerIds.length < 2) { + return NextResponse.json( + { error: "At least 2 player IDs are required for merging" }, + { status: 400 } + ); + } + + // Fetch all players + const players = await prisma.player.findMany({ + where: { id: { in: playerIds } }, + }); + + if (players.length !== playerIds.length) { + return NextResponse.json( + { error: "One or more players not found" }, + { status: 404 } + ); + } + + // First player becomes the canonical player (target) + const [canonicalPlayer, ...duplicatePlayers] = players.sort((a, b) => a.id - b.id); + + console.log(`Merging ${duplicatePlayers.length} players into ${canonicalPlayer.name}`); + + // Merge stats from duplicates into canonical player + let totalGamesPlayed = canonicalPlayer.gamesPlayed; + let totalWins = canonicalPlayer.wins; + let totalLosses = canonicalPlayer.losses; + + for (const dupPlayer of duplicatePlayers) { + totalGamesPlayed += dupPlayer.gamesPlayed; + totalWins += dupPlayer.wins; + totalLosses += dupPlayer.losses; + } + + // Update canonical player with merged stats + await prisma.player.update({ + where: { id: canonicalPlayer.id }, + data: { + gamesPlayed: totalGamesPlayed, + wins: totalWins, + losses: totalLosses, + }, + }); + + // Update all foreign key references to point to canonical player + const updates = []; + + // Update matches - team1P1Id + updates.push( + prisma.match.updateMany({ + where: { team1P1Id: { in: duplicatePlayers.map(p => p.id) } }, + data: { team1P1Id: canonicalPlayer.id }, + }) + ); + + // Update matches - team1P2Id + updates.push( + prisma.match.updateMany({ + where: { team1P2Id: { in: duplicatePlayers.map(p => p.id) } }, + data: { team1P2Id: canonicalPlayer.id }, + }) + ); + + // Update matches - team2P1Id + updates.push( + prisma.match.updateMany({ + where: { team2P1Id: { in: duplicatePlayers.map(p => p.id) } }, + data: { team2P1Id: canonicalPlayer.id }, + }) + ); + + // Update matches - team2P2Id + updates.push( + prisma.match.updateMany({ + where: { team2P2Id: { in: duplicatePlayers.map(p => p.id) } }, + data: { team2P2Id: canonicalPlayer.id }, + }) + ); + + // Update event participants - handle unique constraint + for (const dupPlayer of duplicatePlayers) { + const dupParticipants = await prisma.eventParticipant.findMany({ + where: { playerId: dupPlayer.id }, + }); + + for (const dupParticipant of dupParticipants) { + // Check if canonical player already participates in this event + const existingParticipant = await prisma.eventParticipant.findFirst({ + where: { + eventId: dupParticipant.eventId, + playerId: canonicalPlayer.id, + }, + }); + + if (existingParticipant) { + // Delete the duplicate participant + await prisma.eventParticipant.delete({ + where: { id: dupParticipant.id }, + }); + } else { + // Update the participant to use canonical player + await prisma.eventParticipant.update({ + where: { id: dupParticipant.id }, + data: { playerId: canonicalPlayer.id }, + }); + } + } + } + + // Update partnership games + updates.push( + prisma.partnershipGame.updateMany({ + where: { player1Id: { in: duplicatePlayers.map(p => p.id) } }, + data: { player1Id: canonicalPlayer.id }, + }) + ); + + updates.push( + prisma.partnershipGame.updateMany({ + where: { player2Id: { in: duplicatePlayers.map(p => p.id) } }, + data: { player2Id: canonicalPlayer.id }, + }) + ); + + // Update partnership stats - merge them + for (const dupPlayer of duplicatePlayers) { + const partnerships = await prisma.partnershipStat.findMany({ + where: { + OR: [ + { player1Id: dupPlayer.id }, + { player2Id: dupPlayer.id }, + ], + }, + }); + + for (const partnership of partnerships) { + const otherPlayerId = partnership.player1Id === dupPlayer.id + ? partnership.player2Id + : partnership.player1Id; + + // Check if a partnership already exists between canonical and other player + const existingPartnership = await prisma.partnershipStat.findFirst({ + where: { + OR: [ + { + player1Id: canonicalPlayer.id, + player2Id: otherPlayerId + }, + { + player1Id: otherPlayerId, + player2Id: canonicalPlayer.id + }, + ], + }, + }); + + if (existingPartnership) { + // Merge stats into existing partnership and delete the old one + await prisma.partnershipStat.update({ + where: { id: existingPartnership.id }, + data: { + gamesPlayed: { increment: partnership.gamesPlayed }, + wins: { increment: partnership.wins }, + losses: { increment: partnership.losses }, + totalEloChange: { increment: partnership.totalEloChange }, + lastPlayed: partnership.lastPlayed && existingPartnership.lastPlayed + ? (partnership.lastPlayed > existingPartnership.lastPlayed + ? partnership.lastPlayed + : existingPartnership.lastPlayed) + : partnership.lastPlayed || existingPartnership.lastPlayed, + }, + }); + // Delete the duplicate partnership + await prisma.partnershipStat.delete({ + where: { id: partnership.id }, + }); + } else { + // Update partnership to use canonical player + const isPlayer1 = partnership.player1Id === dupPlayer.id; + await prisma.partnershipStat.update({ + where: { id: partnership.id }, + data: isPlayer1 + ? { player1Id: canonicalPlayer.id } + : { player2Id: canonicalPlayer.id }, + }); + } + } + } + + // Execute all updates + await Promise.all(updates); + console.log(`Updated foreign key references`); + + // Delete duplicate players + for (const dupPlayer of duplicatePlayers) { + await prisma.player.delete({ + where: { id: dupPlayer.id }, + }); + } + console.log(`Deleted ${duplicatePlayers.length} duplicate players`); + + // RECALCULATE ALL ELO FROM SCRATCH + // This is critical - after merging players, all match data has changed + // so we need to recalculate everyone's ELO from the beginning + console.log('Recalculating all Elo ratings from scratch...'); + const recalcResult = await recalculateAllElo(prisma); + console.log(`Elo recalculation complete:`, recalcResult); + + return NextResponse.json({ + success: true, + message: `Successfully merged ${duplicatePlayers.length} players into "${canonicalPlayer.name}"`, + canonicalPlayer: { + id: canonicalPlayer.id, + name: canonicalPlayer.name, + }, + recalculation: recalcResult, + }); + } catch (error: unknown) { + console.error("Error merging players:", error); + const message = error instanceof Error ? error.message : "Failed to merge players"; + return NextResponse.json( + { error: message }, + { status: 500 } + ); + } +}