feat: add player merge functionality with automatic Elo recalculation

This commit is contained in:
2026-03-30 22:04:31 -07:00
parent 6001eda9aa
commit fa7a422ce3
2 changed files with 415 additions and 55 deletions
+263
View File
@@ -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 }
);
}
}