feat: add player merge functionality with automatic Elo recalculation
This commit is contained in:
+152
-55
@@ -23,6 +23,11 @@ export default function AdminPlayersPage() {
|
||||
const [newName, setNewName] = useState("")
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
// Merge player state
|
||||
const [mergeMode, setMergeMode] = useState(false)
|
||||
const [selectedPlayers, setSelectedPlayers] = useState<number[]>([])
|
||||
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
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => setMergeMode(!mergeMode)}
|
||||
className={`px-4 py-2 rounded-md shadow-sm text-sm font-medium ${
|
||||
mergeMode
|
||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{mergeMode ? 'Cancel Merge' : 'Merge Players'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mergeMode && selectedPlayers.length > 0 && (
|
||||
<div className="mb-4 p-4 bg-purple-50 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-purple-700">
|
||||
{selectedPlayers.length} player{selectedPlayers.length !== 1 ? 's' : ''} selected for merging
|
||||
</span>
|
||||
<button
|
||||
onClick={handleMerge}
|
||||
disabled={isMerging || selectedPlayers.length < 2}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-md hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{isMerging ? 'Merging...' : `Merge ${selectedPlayers.length} Players`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-100 text-red-700 rounded">
|
||||
{error}
|
||||
@@ -129,63 +204,85 @@ export default function AdminPlayersPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{players.map((player) => (
|
||||
<tr key={player.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 h-10 w-10 bg-gray-200 rounded-full flex items-center justify-center">
|
||||
<span className="text-sm font-medium text-gray-600">
|
||||
{player.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{player.name}
|
||||
{players.map((player) => {
|
||||
const isSelected = selectedPlayers.includes(player.id)
|
||||
return (
|
||||
<tr
|
||||
key={player.id}
|
||||
className={`${isSelected ? 'bg-purple-50' : 'hover:bg-gray-50'} ${
|
||||
mergeMode ? 'cursor-pointer' : ''
|
||||
}`}
|
||||
onClick={() => mergeMode && togglePlayerSelection(player.id)}
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
{mergeMode && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => togglePlayerSelection(player.id)}
|
||||
className="mr-3 h-4 w-4 text-purple-600 focus:ring-purple-500 border-gray-300 rounded"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-shrink-0 h-10 w-10 bg-gray-200 rounded-full flex items-center justify-center">
|
||||
<span className="text-sm font-medium text-gray-600">
|
||||
{player.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{player.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{player.currentElo}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{player.gamesPlayed}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{player.wins}-{player.losses}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{player.gamesPlayed > 0
|
||||
? ((player.wins / player.gamesPlayed) * 100).toFixed(1) + '%'
|
||||
: 'N/A'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{player.user ? (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
{player.user.email}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400 italic">No account</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div className="flex space-x-2">
|
||||
<Link
|
||||
href={`/players/${player.id}/profile`}
|
||||
className="text-green-600 hover:text-green-900"
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleEditClick(player)}
|
||||
className="text-blue-600 hover:text-blue-900"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{player.currentElo}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{player.gamesPlayed}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{player.wins}-{player.losses}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{player.gamesPlayed > 0
|
||||
? ((player.wins / player.gamesPlayed) * 100).toFixed(1) + '%'
|
||||
: 'N/A'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{player.user ? (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
{player.user.email}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400 italic">No account</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div className="flex space-x-2">
|
||||
<Link
|
||||
href={`/players/${player.id}/profile`}
|
||||
className="text-green-600 hover:text-green-900"
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
{!mergeMode && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleEditClick(player)
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-900"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -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