bb6be245b7
## 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>
379 lines
11 KiB
TypeScript
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 });
|
|
}
|
|
}
|