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.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 }
+ );
+ }
+}