feat: add player merge functionality with automatic Elo recalculation
This commit is contained in:
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user