Files
euchre_camp/src/app/api/tournaments/[id]/games/bulk/route.ts
T
david bb6be245b7
Release / release (push) Failing after 9s
Build CI Images / build-ci-base (push) Failing after 18s
feat: Implement tournament schedule tab and fix E2E tests (#27)
## Summary

This PR implements the tournament schedule tab functionality and fixes all remaining E2E test failures.

### Changes Included

1. **Tournament Schedule Feature**
   - Added tournament schedule page at `/admin/tournaments/[id]/schedule`
   - Implemented "Generate Schedule" button functionality
   - Added schedule generation logic for round-robin tournaments

2. **E2E Test Fixes**
   - Fixed database connection issues in production builds
   - Improved test reliability with better error handling and debugging
   - Updated test infrastructure to use environment variables instead of hardcoded values

3. **CI/CD Updates**
   - Added E2E test job to PR workflow
   - Configured tests to run against development database
   - Moved database password to Gitea secrets

4. **Code Quality**
   - Removed hardcoded passwords from codebase
   - Improved Prisma client configuration
   - Enhanced authentication and navigation components

### Test Results
All 16 E2E test scenarios are now passing:
- Authentication tests: 
- Registration tests: 
- Tournament schedule tests: 
- Player schedule tests: 
- Admin navigation tests: 

### Database Configuration
- Tests run against `euchre_camp_dev` database
- Production builds use environment variables for database configuration
- Database password stored in Gitea secrets as `DB_PASSWORD`

### CI Pipeline
The PR workflow now includes:
1. Unit tests
2. E2E tests (using production build)
3. Version bump analysis

E2E tests must pass before PR can be merged.

Reviewed-on: #27
Co-authored-by: David Gwilliam <dhgwilliam@gmail.com>
Co-committed-by: David Gwilliam <dhgwilliam@gmail.com>
2026-04-27 01:59:16 +00:00

247 lines
7.6 KiB
TypeScript

import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { canManageTournament } from "@/lib/permissions";
import { getSession } from "@/lib/auth-simple";
import { calculateTeamElo, calculateTeamEloChange } from "@/lib/elo-utils";
const K_FACTOR = 32; // Standard K-factor for Elo calculations
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const tournamentId = parseInt(id, 10);
if (isNaN(tournamentId)) {
return NextResponse.json(
{ error: "Invalid tournament ID" },
{ status: 400 }
);
}
const body = await request.json();
const { games } = body;
if (!games || !Array.isArray(games) || games.length === 0) {
return NextResponse.json(
{ error: "games array is required" },
{ status: 400 }
);
}
// Check permissions
const permission = await canManageTournament(tournamentId);
if (!permission.allowed) {
return NextResponse.json(
{ error: permission.reason || 'Insufficient permissions' },
{ status: 403 }
);
}
// Get current user
const session = await getSession();
const userId = session?.user?.id || null;
let importedCount = 0;
let errorCount = 0;
const errors: string[] = [];
// Process each game
for (const [index, game] of games.entries()) {
try {
// Find players by name (case-insensitive partial match)
const [player1, player2, player3, player4] = await Promise.all([
findPlayerByName(game.player1),
findPlayerByName(game.player2),
findPlayerByName(game.player3),
findPlayerByName(game.player4),
]);
// Check if all players were found
if (!player1 || !player2 || !player3 || !player4) {
const missing = [];
if (!player1) missing.push(game.player1);
if (!player2) missing.push(game.player2);
if (!player3) missing.push(game.player3);
if (!player4) missing.push(game.player4);
errors.push(`Game ${index + 1}: Player not found: ${missing.join(", ")}`);
errorCount++;
continue;
}
// Calculate Elo changes
const team1Rating = calculateTeamElo(player1.currentElo, player2.currentElo);
const team2Rating = calculateTeamElo(player3.currentElo, player4.currentElo);
const team1Won = game.score1 > game.score2;
const team2Won = game.score2 > game.score1;
const isTie = game.score1 === game.score2;
const team1ScoreActual = isTie ? 0.5 : (team1Won ? 1 : 0);
const team2ScoreActual = isTie ? 0.5 : (team2Won ? 1 : 0);
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;
// Create match
await prisma.match.create({
data: {
eventId: tournamentId,
player1P1Id: player1.id,
player1P2Id: player2.id,
player2P1Id: player3.id,
player2P2Id: player4.id,
team1Score: game.score1,
team2Score: game.score2,
status: "completed",
playedAt: new Date(),
createdById: userId,
},
});
// Update player stats
await updatePlayerStats(player1.id, team1Won, player1EloChange);
await updatePlayerStats(player2.id, team1Won, player2EloChange);
await updatePlayerStats(player3.id, team2Won, player3EloChange);
await updatePlayerStats(player4.id, team2Won, player4EloChange);
// Update partnership stats
await updatePartnershipStats(player1.id, player2.id, team1Won, player1EloChange);
await updatePartnershipStats(player3.id, player4.id, team2Won, player3EloChange);
// Add players as participants if not already added
for (const player of [player1, player2, player3, player4]) {
try {
// Check if participant already exists
const existing = await prisma.eventParticipant.findFirst({
where: {
eventId: tournamentId,
playerId: player.id,
},
});
if (!existing) {
await prisma.eventParticipant.create({
data: {
eventId: tournamentId,
playerId: player.id,
status: "participated",
registrationDate: new Date(),
},
});
}
} catch {
// Participant already exists, which is fine
console.log(`Participant ${player.id} already exists in tournament ${tournamentId}`);
}
}
importedCount++;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
errors.push(`Game ${index + 1}: ${message}`);
errorCount++;
}
}
return NextResponse.json({
importedCount,
errorCount,
errors: errors.length > 0 ? errors : undefined,
});
} catch (error: unknown) {
console.error("Bulk game submission error:", error);
const message = error instanceof Error ? error.message : "Failed to submit games";
return NextResponse.json(
{ error: message },
{ status: 500 }
);
}
}
async function findPlayerByName(name: string) {
// Try to find existing player with exact name match first (case-sensitive)
let player = await prisma.player.findFirst({
where: {
name: name,
},
});
if (!player) {
// Try partial match (case-sensitive)
player = await prisma.player.findFirst({
where: {
name: {
contains: name,
},
},
orderBy: { name: "asc" },
});
}
return player;
}
async function updatePlayerStats(playerId: number, won: boolean, eloChange: number) {
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: won ? player.wins + 1 : player.wins,
losses: won ? player.losses : player.losses + 1,
},
});
}
async function updatePartnershipStats(player1Id: number, player2Id: number, won: boolean, eloChange: number) {
const [smallId, largeId] = player1Id < player2Id ? [player1Id, player2Id] : [player2Id, player1Id];
const existing = await prisma.partnershipStat.findFirst({
where: {
player1Id: smallId,
player2Id: largeId,
},
});
if (existing) {
await prisma.partnershipStat.update({
where: { id: existing.id },
data: {
gamesPlayed: { increment: 1 },
wins: won ? { increment: 1 } : undefined,
losses: won ? undefined : { increment: 1 },
totalEloChange: { increment: eloChange },
lastPlayed: new Date(),
},
});
} else {
await prisma.partnershipStat.create({
data: {
player1Id: smallId,
player2Id: largeId,
gamesPlayed: 1,
wins: won ? 1 : 0,
losses: won ? 0 : 1,
totalEloChange: eloChange,
lastPlayed: new Date(),
},
});
}
}