377 lines
12 KiB
TypeScript
377 lines
12 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import { prisma } from "@/lib/prisma";
|
|
import { getSession } from "@/lib/auth-simple";
|
|
import { hasRole, canManageMatch } from "@/lib/permissions";
|
|
|
|
import { calculateTeamElo, calculateTeamEloChange } from "@/lib/elo-utils";
|
|
import { calculateOpenSkillRatings, fromOpenSkillRating } from "@/lib/openskill-utils";
|
|
import { calculateGlicko2Ratings } from "@/lib/glicko2-utils";
|
|
|
|
const K_FACTOR = 32; // Standard K-factor for Elo calculations
|
|
|
|
/**
|
|
* GET /api/matches
|
|
*
|
|
* Get matches (admin only)
|
|
* Requires: club_admin, site_admin, or tournament_admin role
|
|
*/
|
|
export async function GET() {
|
|
try {
|
|
const session = await getSession();
|
|
if (!session) {
|
|
return NextResponse.json(
|
|
{ error: "Not authenticated" },
|
|
{ status: 403 }
|
|
);
|
|
}
|
|
|
|
const userId = session.user?.id;
|
|
if (!userId) {
|
|
return NextResponse.json(
|
|
{ error: "User ID not found in session" },
|
|
{ status: 403 }
|
|
);
|
|
}
|
|
|
|
// Fetch user from database to get current role
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: { role: true }
|
|
});
|
|
|
|
if (!user) {
|
|
return NextResponse.json(
|
|
{ error: "User not found" },
|
|
{ status: 403 }
|
|
);
|
|
}
|
|
|
|
const userRole = user.role as 'player' | 'tournament_admin' | 'club_admin' | 'site_admin';
|
|
|
|
let matches;
|
|
if (userRole === 'club_admin' || userRole === 'site_admin') {
|
|
// Club admins and site admins can see all matches
|
|
matches = await prisma.match.findMany({
|
|
include: {
|
|
event: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
},
|
|
},
|
|
player1P1: { select: { id: true, name: true } },
|
|
player1P2: { select: { id: true, name: true } },
|
|
player2P1: { select: { id: true, name: true } },
|
|
player2P2: { select: { id: true, name: true } },
|
|
},
|
|
orderBy: { playedAt: 'desc' },
|
|
});
|
|
} else {
|
|
// Regular players cannot view matches via this API (should not happen if UI is protected)
|
|
return NextResponse.json(
|
|
{ error: "Insufficient permissions to view matches" },
|
|
{ status: 403 }
|
|
);
|
|
}
|
|
|
|
return NextResponse.json(matches);
|
|
} catch (error: unknown) {
|
|
console.error("Error fetching matches:", error);
|
|
const message = error instanceof Error ? error.message : "Failed to fetch matches";
|
|
return NextResponse.json(
|
|
{ error: message },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* POST /api/matches
|
|
*
|
|
* Create a casual match (not associated with a tournament)
|
|
* Requires: player role or higher
|
|
*/
|
|
export async function POST(request: Request) {
|
|
try {
|
|
const body = await request.json();
|
|
const {
|
|
team1P1Id,
|
|
team1P2Id,
|
|
team2P1Id,
|
|
team2P2Id,
|
|
team1Score,
|
|
team2Score,
|
|
playedAt,
|
|
eventId,
|
|
isCasual: isCasualFromRequest,
|
|
} = body;
|
|
|
|
// Determine if match is casual
|
|
// If eventId is provided, it's a tournament match (not casual by default)
|
|
// If eventId is null, it's a casual match (casual by default)
|
|
const isCasual = isCasualFromRequest !== undefined ? isCasualFromRequest : (eventId ? false : true);
|
|
|
|
// Validate required fields
|
|
if (!team1P1Id || !team1P2Id || !team2P1Id || !team2P2Id) {
|
|
return NextResponse.json(
|
|
{ error: "All four player IDs are required" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
if (team1Score === undefined || team2Score === undefined) {
|
|
return NextResponse.json(
|
|
{ error: "Both team scores are required" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Check permissions - need at least player role
|
|
const session = await getSession();
|
|
if (!session) {
|
|
return NextResponse.json(
|
|
{ error: "Not authenticated" },
|
|
{ status: 403 }
|
|
);
|
|
}
|
|
|
|
const userId = session.user?.id || null;
|
|
|
|
// Fetch player data
|
|
const [player1, player2, player3, player4] = await Promise.all([
|
|
prisma.player.findUnique({ where: { id: parseInt(team1P1Id) } }),
|
|
prisma.player.findUnique({ where: { id: parseInt(team1P2Id) } }),
|
|
prisma.player.findUnique({ where: { id: parseInt(team2P1Id) } }),
|
|
prisma.player.findUnique({ where: { id: parseInt(team2P2Id) } }),
|
|
]);
|
|
|
|
// Check all players exist
|
|
const missingPlayers: number[] = [];
|
|
if (!player1) missingPlayers.push(parseInt(team1P1Id));
|
|
if (!player2) missingPlayers.push(parseInt(team1P2Id));
|
|
if (!player3) missingPlayers.push(parseInt(team2P1Id));
|
|
if (!player4) missingPlayers.push(parseInt(team2P2Id));
|
|
|
|
if (missingPlayers.length > 0) {
|
|
return NextResponse.json(
|
|
{ error: `Players not found: ${missingPlayers.join(", ")}` },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Calculate match outcome
|
|
const team1Won = team1Score > team2Score;
|
|
const team2Won = team2Score > team1Score;
|
|
const isTie = team1Score === team2Score;
|
|
|
|
const team1ScoreActual = isTie ? 0.5 : (team1Won ? 1 : 0);
|
|
const team2ScoreActual = isTie ? 0.5 : (team2Won ? 1 : 0);
|
|
|
|
// Calculate Elo changes
|
|
const team1Rating = calculateTeamElo(player1!.currentElo, player2!.currentElo);
|
|
const team2Rating = calculateTeamElo(player3!.currentElo, player4!.currentElo);
|
|
|
|
const team1EloChange = calculateTeamEloChange(team1Rating, team2Rating, team1ScoreActual, K_FACTOR);
|
|
const team2EloChange = calculateTeamEloChange(team2Rating, team1Rating, team2ScoreActual, K_FACTOR);
|
|
|
|
const player1EloChange = team1EloChange / 2;
|
|
const player2EloChange = team1EloChange / 2;
|
|
const player3EloChange = team2EloChange / 2;
|
|
const player4EloChange = team2EloChange / 2;
|
|
|
|
// Calculate OpenSkill ratings
|
|
const player1OpenSkill = { mu: player1!.currentElo, sigma: 8.33 };
|
|
const player2OpenSkill = { mu: player2!.currentElo, sigma: 8.33 };
|
|
const player3OpenSkill = { mu: player3!.currentElo, sigma: 8.33 };
|
|
const player4OpenSkill = { mu: player4!.currentElo, sigma: 8.33 };
|
|
|
|
const rankings = isTie ? [0, 0] : team1Won ? [0, 1] : [1, 0];
|
|
const openSkillTeams = [[player1OpenSkill, player2OpenSkill], [player3OpenSkill, player4OpenSkill]];
|
|
const newOpenSkillRatings = calculateOpenSkillRatings(openSkillTeams, rankings);
|
|
|
|
// Calculate Glicko2 ratings
|
|
const Glicko2 = require('glicko2').Glicko2;
|
|
const glicko = new Glicko2();
|
|
const glicko1 = glicko.makePlayer(player1!.currentElo, 350, 0.06);
|
|
const glicko2 = glicko.makePlayer(player2!.currentElo, 350, 0.06);
|
|
const glicko3 = glicko.makePlayer(player3!.currentElo, 350, 0.06);
|
|
const glicko4 = glicko.makePlayer(player4!.currentElo, 350, 0.06);
|
|
|
|
const glickoMatches: [any, any, number][] = [];
|
|
const outcome = isTie ? 0.5 : team1Won ? 1 : 0;
|
|
|
|
[glicko1, glicko2].forEach(p1 => {
|
|
[glicko3, glicko4].forEach(p2 => {
|
|
glickoMatches.push([p1, p2, outcome]);
|
|
});
|
|
});
|
|
|
|
glicko.updateRatings(glickoMatches);
|
|
|
|
// Create match
|
|
const match = await prisma.match.create({
|
|
data: {
|
|
eventId: eventId ? parseInt(eventId) : null,
|
|
isCasual: isCasual,
|
|
playedAt: playedAt ? new Date(playedAt) : new Date(),
|
|
player1P1Id: parseInt(team1P1Id),
|
|
player1P2Id: parseInt(team1P2Id),
|
|
player2P1Id: parseInt(team2P1Id),
|
|
player2P2Id: parseInt(team2P2Id),
|
|
team1Score,
|
|
team2Score,
|
|
status: "completed",
|
|
createdById: userId,
|
|
},
|
|
});
|
|
|
|
// Update player stats (Elo)
|
|
await updatePlayerStats(parseInt(team1P1Id), team1Won, player1EloChange, isTie);
|
|
await updatePlayerStats(parseInt(team1P2Id), team1Won, player2EloChange, isTie);
|
|
await updatePlayerStats(parseInt(team2P1Id), team2Won, player3EloChange, isTie);
|
|
await updatePlayerStats(parseInt(team2P2Id), team2Won, player4EloChange, isTie);
|
|
|
|
// Update OpenSkill ratings
|
|
await updateOpenSkillRating(parseInt(team1P1Id), fromOpenSkillRating(newOpenSkillRatings[0][0]), team1Won, isTie);
|
|
await updateOpenSkillRating(parseInt(team1P2Id), fromOpenSkillRating(newOpenSkillRatings[0][1]), team1Won, isTie);
|
|
await updateOpenSkillRating(parseInt(team2P1Id), fromOpenSkillRating(newOpenSkillRatings[1][0]), team2Won, isTie);
|
|
await updateOpenSkillRating(parseInt(team2P2Id), fromOpenSkillRating(newOpenSkillRatings[1][1]), team2Won, isTie);
|
|
|
|
// Update Glicko2 ratings
|
|
await updateGlicko2Rating(parseInt(team1P1Id), glicko1.getRating(), glicko1.getRd(), glicko1.getVol(), team1Won, isTie);
|
|
await updateGlicko2Rating(parseInt(team1P2Id), glicko2.getRating(), glicko2.getRd(), glicko2.getVol(), team1Won, isTie);
|
|
await updateGlicko2Rating(parseInt(team2P1Id), glicko3.getRating(), glicko3.getRd(), glicko3.getVol(), team2Won, isTie);
|
|
await updateGlicko2Rating(parseInt(team2P2Id), glicko4.getRating(), glicko4.getRd(), glicko4.getVol(), team2Won, isTie);
|
|
|
|
// Update partnership stats
|
|
await updatePartnershipStats(parseInt(team1P1Id), parseInt(team1P2Id), team1Won, player1EloChange, isTie);
|
|
await updatePartnershipStats(parseInt(team2P1Id), parseInt(team2P2Id), team2Won, player3EloChange, isTie);
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
match,
|
|
});
|
|
} catch (error: unknown) {
|
|
console.error("Error creating casual match:", error);
|
|
const message = error instanceof Error ? error.message : "Failed to create match";
|
|
return NextResponse.json(
|
|
{ error: message },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
async function updatePlayerStats(playerId: number, won: boolean, eloChange: number, isTie: boolean = false) {
|
|
const player = await prisma.player.findUnique({
|
|
where: { id: playerId },
|
|
});
|
|
|
|
if (!player) {
|
|
throw new Error(`Player ${playerId} not found`);
|
|
}
|
|
|
|
await prisma.player.update({
|
|
where: { id: playerId },
|
|
data: {
|
|
currentElo: player.currentElo + Math.round(eloChange),
|
|
gamesPlayed: player.gamesPlayed + 1,
|
|
wins: isTie ? player.wins : (won ? player.wins + 1 : player.wins),
|
|
losses: isTie ? player.losses : (won ? player.losses : player.losses + 1),
|
|
},
|
|
});
|
|
}
|
|
|
|
async function updatePartnershipStats(player1Id: number, player2Id: number, won: boolean, eloChange: number, isTie: boolean = false) {
|
|
const [smallId, largeId] = player1Id < player2Id ? [player1Id, player2Id] : [player2Id, player1Id];
|
|
|
|
const existing = await prisma.partnershipStat.findFirst({
|
|
where: {
|
|
player1Id: smallId,
|
|
player2Id: largeId,
|
|
},
|
|
});
|
|
|
|
const updateData = isTie
|
|
? {
|
|
gamesPlayed: { increment: 1 },
|
|
totalEloChange: { increment: eloChange },
|
|
lastPlayed: new Date(),
|
|
}
|
|
: {
|
|
gamesPlayed: { increment: 1 },
|
|
wins: won ? { increment: 1 } : undefined,
|
|
losses: won ? undefined : { increment: 1 },
|
|
totalEloChange: { increment: eloChange },
|
|
lastPlayed: new Date(),
|
|
};
|
|
|
|
if (existing) {
|
|
await prisma.partnershipStat.update({
|
|
where: { id: existing.id },
|
|
data: updateData,
|
|
});
|
|
} else {
|
|
await prisma.partnershipStat.create({
|
|
data: {
|
|
player1Id: smallId,
|
|
player2Id: largeId,
|
|
gamesPlayed: 1,
|
|
wins: isTie ? 0 : (won ? 1 : 0),
|
|
losses: isTie ? 0 : (won ? 0 : 1),
|
|
totalEloChange: eloChange,
|
|
lastPlayed: new Date(),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
async function updateOpenSkillRating(playerId: number, rating: number, won: boolean, isTie: boolean = false) {
|
|
await prisma.openSkillRating.upsert({
|
|
where: { playerId },
|
|
update: {
|
|
rating,
|
|
gamesPlayed: { increment: 1 },
|
|
wins: won ? { increment: 1 } : undefined,
|
|
losses: !won && !isTie ? { increment: 1 } : undefined,
|
|
draws: isTie ? { increment: 1 } : undefined,
|
|
lastUpdated: new Date(),
|
|
},
|
|
create: {
|
|
playerId,
|
|
rating,
|
|
gamesPlayed: 1,
|
|
wins: won ? 1 : 0,
|
|
losses: !won && !isTie ? 1 : 0,
|
|
draws: isTie ? 1 : 0,
|
|
lastUpdated: new Date(),
|
|
},
|
|
});
|
|
}
|
|
|
|
async function updateGlicko2Rating(playerId: number, rating: number, deviation: number, volatility: number, won: boolean, isTie: boolean = false) {
|
|
await prisma.glicko2Rating.upsert({
|
|
where: { playerId },
|
|
update: {
|
|
rating,
|
|
deviation,
|
|
volatility,
|
|
gamesPlayed: { increment: 1 },
|
|
wins: won ? { increment: 1 } : undefined,
|
|
losses: !won && !isTie ? { increment: 1 } : undefined,
|
|
draws: isTie ? { increment: 1 } : undefined,
|
|
lastUpdated: new Date(),
|
|
},
|
|
create: {
|
|
playerId,
|
|
rating,
|
|
deviation,
|
|
volatility,
|
|
gamesPlayed: 1,
|
|
wins: won ? 1 : 0,
|
|
losses: !won && !isTie ? 1 : 0,
|
|
draws: isTie ? 1 : 0,
|
|
lastUpdated: new Date(),
|
|
},
|
|
});
|
|
}
|