272 lines
8.6 KiB
TypeScript
272 lines
8.6 KiB
TypeScript
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 elo snapshots for duplicate players (they will be recalculated anyway)
|
|
for (const dupPlayer of duplicatePlayers) {
|
|
await prisma.eloSnapshot.deleteMany({
|
|
where: { playerId: dupPlayer.id },
|
|
});
|
|
}
|
|
console.log(`Deleted elo snapshots for ${duplicatePlayers.length} duplicate players`);
|
|
|
|
// 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 }
|
|
);
|
|
}
|
|
}
|