Files
euchre_camp/src/app/api/tournaments/[id]/schedule/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

379 lines
11 KiB
TypeScript

import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { canManageTournament } from "@/lib/permissions";
import { generateRoundRobin, validateScheduleInput, generateVariableRoundRobin, expectedRounds } from "@/lib/schedule-generator";
import { generateTeams, generateTeamsWithRotation, generateRandomTeams, type Player, type Team as TeamPairing } from "@/lib/team-generator";
interface RouteParams {
params: Promise<{
id: string;
}>;
}
/**
* GET /api/tournaments/[id]/schedule
*
* Fetch the tournament schedule (rounds with matchups).
*/
export async function GET(_request: Request, { params }: RouteParams) {
try {
const { id } = await params;
const tournamentId = parseInt(id);
if (isNaN(tournamentId)) {
return NextResponse.json(
{ error: "Invalid tournament ID" },
{ status: 400 }
);
}
const permission = await canManageTournament(tournamentId);
if (!permission.allowed) {
return NextResponse.json(
{ error: permission.reason || "Not authorized to view this tournament" },
{ status: 403 }
);
}
const tournament = await prisma.event.findUnique({
where: { id: tournamentId },
include: {
rounds: {
orderBy: { roundNumber: "asc" },
include: {
bracketMatchups: {
include: {
player1P1: true,
player1P2: true,
player2P1: true,
player2P2: true,
match: true,
},
},
},
},
},
});
if (!tournament) {
return NextResponse.json(
{ error: "Tournament not found" },
{ status: 404 }
);
}
return NextResponse.json({ rounds: tournament.rounds });
} catch (error: unknown) {
console.error("Error fetching schedule:", error);
const message =
error instanceof Error ? error.message : "Failed to fetch schedule";
return NextResponse.json({ error: message }, { status: 500 });
}
}
/**
* POST /api/tournaments/[id]/schedule
*
* Generate a round-robin schedule for the tournament.
* Creates TournamentRound and BracketMatchup records.
*/
export async function POST(_request: Request, { params }: RouteParams) {
try {
const { id } = await params;
const tournamentId = parseInt(id);
if (isNaN(tournamentId)) {
return NextResponse.json(
{ error: "Invalid tournament ID" },
{ status: 400 }
);
}
const permission = await canManageTournament(tournamentId);
if (!permission.allowed) {
return NextResponse.json(
{ error: permission.reason || "Not authorized to manage this tournament" },
{ status: 403 }
);
}
// Check tournament exists
const tournament = await prisma.event.findUnique({
where: { id: tournamentId },
include: {
participants: {
include: {
player: true,
},
},
rounds: true,
},
});
if (!tournament) {
return NextResponse.json(
{ error: "Tournament not found" },
{ status: 404 }
);
}
// Check if schedule already exists and delete it
if (tournament.rounds.length > 0) {
// Delete existing rounds and matchups before regenerating
await prisma.bracketMatchup.deleteMany({
where: { eventId: tournamentId },
});
await prisma.tournamentRound.deleteMany({
where: { eventId: tournamentId },
});
}
// Get participants as players
const participants: Player[] = tournament.participants.map((p) => ({
id: p.player.id,
name: p.player.name,
currentElo: p.player.currentElo,
}));
// Check minimum participants
if (participants.length < 2) {
return NextResponse.json(
{ error: "At least 2 participants are required to generate a schedule" },
{ status: 400 }
);
}
// Generate teams based on configuration
const teamDurability = tournament.teamDurability || "permanent";
const partnerRotation = (tournament.partnerRotation || "none") as 'none' | 'minimize_repeat' | 'maximize_even' | 'elo_based';
const allowByes = tournament.allowByes ?? true;
// Determine number of teams from participants
const tempResult = generateTeams(participants, partnerRotation, allowByes);
const teamCount = tempResult.teams.length;
if (teamCount < 2) {
return NextResponse.json(
{ error: "At least 2 teams (4 players) are required to generate a schedule" },
{ status: 400 }
);
}
// Calculate number of rounds needed
const numRounds = expectedRounds(teamCount);
if (teamDurability === "permanent") {
// ============================================
// OPTION 1: FIXED TEAMS
// Teams are formed once and stay the same throughout
// ============================================
// Generate teams once for permanent team tournaments
const result = generateTeams(participants, partnerRotation, allowByes);
const teamPairings = result.teams.map((t) => ({
player1Id: t.player1Id,
player2Id: t.player2Id,
}));
// Validate schedule input
const validation = validateScheduleInput(teamPairings);
if (!validation.valid) {
return NextResponse.json(
{ error: validation.error },
{ status: 400 }
);
}
// Generate schedule using fixed teams
const schedule = generateRoundRobin(teamPairings);
// Create rounds and matchups in a transaction
const created = await prisma.$transaction(
schedule.map((round) =>
prisma.tournamentRound.create({
data: {
eventId: tournamentId,
roundNumber: round.roundNumber,
status: "pending",
bracketMatchups: {
create: round.matchups.map((matchup, idx) => ({
eventId: tournamentId,
player1P1Id: matchup.player1P1Id,
player1P2Id: matchup.player1P2Id,
player2P1Id: matchup.player2P1Id,
player2P2Id: matchup.player2P2Id,
bracketPosition: idx + 1,
status: "pending",
})),
},
},
include: {
bracketMatchups: {
include: {
player1P1: true,
player1P2: true,
player2P1: true,
player2P2: true,
},
},
},
})
)
);
return NextResponse.json({
success: true,
roundsCreated: created.length,
matchupsCreated: created.reduce(
(sum, r) => sum + r.bracketMatchups.length,
0
),
rounds: created,
});
} else if (teamDurability === "variable") {
// ============================================
// OPTION 2: PRE-PLANNED VARIABLE
// Fresh teams each round, generated before tournament starts
// Partners rotate based on selected strategy
// ============================================
// Track partnerships across all rounds to minimize repeats
const allPreviousTeams: TeamPairing[][] = [];
// Create a function that generates teams with rotation
const generateTeamWithRotation = (players: Player[]): TeamPairing[] => {
// For pre-planned variable, we generate fresh teams each round
// using the partner rotation strategy and tracking previous partnerships
const result = generateTeamsWithRotation(players, allPreviousTeams, partnerRotation, allowByes);
// Store the generated teams for future rounds
allPreviousTeams.push(result.teams);
return result.teams;
};
// Generate the schedule with fresh teams each round
const schedule = generateVariableRoundRobin(
participants,
teamCount,
numRounds,
generateTeamWithRotation
);
// Create rounds and matchups in a transaction
const created = await prisma.$transaction(
schedule.map((round) =>
prisma.tournamentRound.create({
data: {
eventId: tournamentId,
roundNumber: round.roundNumber,
status: "pending",
bracketMatchups: {
create: round.matchups.map((matchup, idx) => ({
eventId: tournamentId,
player1P1Id: matchup.player1P1Id,
player1P2Id: matchup.player1P2Id,
player2P1Id: matchup.player2P1Id,
player2P2Id: matchup.player2P2Id,
bracketPosition: idx + 1,
status: "pending",
})),
},
},
include: {
bracketMatchups: {
include: {
player1P1: true,
player1P2: true,
player2P1: true,
player2P2: true,
},
},
},
})
)
);
return NextResponse.json({
success: true,
roundsCreated: created.length,
matchupsCreated: created.reduce(
(sum, r) => sum + r.bracketMatchups.length,
0
),
rounds: created,
});
} else {
// ============================================
// OPTION 3: DYNAMIC/PROGRESSIVE
// Teams formed based on results (bracket-style)
// Cannot pre-generate full schedule
// ============================================
return NextResponse.json({
success: true,
message: "Dynamic tournaments require completing rounds before scheduling the next one",
roundsCreated: 0,
matchupsCreated: 0,
rounds: [],
requiresDynamicScheduling: true,
});
}
} catch (error: unknown) {
console.error("Error generating schedule:", error);
const message =
error instanceof Error ? error.message : "Failed to generate schedule";
return NextResponse.json({ error: message }, { status: 500 });
}
}
/**
* DELETE /api/tournaments/[id]/schedule
*
* Delete all rounds and matchups for a tournament.
*/
export async function DELETE(_request: Request, { params }: RouteParams) {
try {
const { id } = await params;
const tournamentId = parseInt(id);
if (isNaN(tournamentId)) {
return NextResponse.json(
{ error: "Invalid tournament ID" },
{ status: 400 }
);
}
const permission = await canManageTournament(tournamentId);
if (!permission.allowed) {
return NextResponse.json(
{ error: permission.reason || "Not authorized to manage this tournament" },
{ status: 403 }
);
}
// Delete bracket matchups first (FK constraint)
const deletedMatchups = await prisma.bracketMatchup.deleteMany({
where: { eventId: tournamentId },
});
// Delete rounds
const deletedRounds = await prisma.tournamentRound.deleteMany({
where: { eventId: tournamentId },
});
return NextResponse.json({
success: true,
deletedRounds: deletedRounds.count,
deletedMatchups: deletedMatchups.count,
});
} catch (error: unknown) {
console.error("Error deleting schedule:", error);
const message =
error instanceof Error ? error.message : "Failed to delete schedule";
return NextResponse.json({ error: message }, { status: 500 });
}
}