import { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } from "@/utils/supertokens"; import { createServerFn } from "@tanstack/react-start"; import { pbAdmin } from "@/lib/pocketbase/client"; import { tournamentInputSchema, GroupStanding } from "@/features/tournaments/types"; import { logger } from "."; import { z } from "zod"; import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result"; import { serverFnLoggingMiddleware } from "@/utils/activities"; import { fa } from "zod/v4/locales"; import brackets from "@/features/bracket/utils"; import { MatchInput } from "@/features/matches/types"; import { generateSingleEliminationBracket } from "./utils/bracket-generator"; export const listTournaments = createServerFn() .middleware([superTokensFunctionMiddleware]) .handler(async () => toServerResult(pbAdmin.listTournaments) ); export const createTournament = createServerFn() .inputValidator(tournamentInputSchema) .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware]) .handler(async ({ data }) => toServerResult(() => pbAdmin.createTournament(data)) ); export const updateTournament = createServerFn() .inputValidator(z.object({ id: z.string(), updates: tournamentInputSchema.partial() })) .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware]) .handler(async ({ data }) => toServerResult(() => pbAdmin.updateTournament(data.id, data.updates)) ); export const getTournament = createServerFn() .inputValidator(z.string()) .middleware([superTokensFunctionMiddleware]) .handler(async ({ data: tournamentId, context }) => { const isAdmin = context.roles.includes("Admin"); return toServerResult(() => pbAdmin.getTournament(tournamentId, isAdmin)); }); export const getCurrentTournament = createServerFn() .handler(async () => toServerResult(() => pbAdmin.getMostRecentTournament()) ); export const enrollTeam = createServerFn() .inputValidator(z.object({ tournamentId: z.string(), teamId: z.string() })) .middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware]) .handler(async ({ data: { tournamentId, teamId }, context }) => toServerResult(async () => { const userId = context.userAuthId; const isAdmin = context.roles.includes("Admin"); const team = await pbAdmin.getTeam(teamId); if (!team) { throw new Error('Team not found'); } //const isPlayerOnTeam = team.players?.some(player => player.id === userId); //if (!isPlayerOnTeam && !isAdmin) { // throw new Error('You do not have permission to enroll this team'); //} const freeAgents = await pbAdmin.getFreeAgents(tournamentId); for (const player of team.players || []) { const isFreeAgent = freeAgents.some(fa => fa.player?.id === player.id); if (isFreeAgent) { await pbAdmin.unenrollFreeAgent(player.id, tournamentId); } } logger.info('Enrolling team in tournament', { tournamentId, teamId, userId }); const tournament = await pbAdmin.enrollTeam(tournamentId, teamId); return tournament; }) ); export const unenrollTeam = createServerFn() .inputValidator(z.object({ tournamentId: z.string(), teamId: z.string() })) .middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware]) .handler(async ({ data: { tournamentId, teamId }, context }) => toServerResult(() => pbAdmin.unenrollTeam(tournamentId, teamId)) ); export const getUnenrolledTeams = createServerFn() .inputValidator(z.string()) .middleware([superTokensAdminFunctionMiddleware]) .handler(async ({ data: tournamentId }) => toServerResult(() => pbAdmin.getUnenrolledTeams(tournamentId)) ); export const getFreeAgents = createServerFn() .inputValidator(z.string()) .middleware([superTokensFunctionMiddleware]) .handler(async ({ data: tournamentId }) => toServerResult(() => pbAdmin.getFreeAgents(tournamentId)) ); export const enrollFreeAgent = createServerFn() .inputValidator(z.object({ phone: z.string(), tournamentId: z.string() })) .middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware]) .handler(async ({ context, data }) => toServerResult(async () => { const userAuthId = context.userAuthId; const player = await pbAdmin.getPlayerByAuthId(userAuthId); if (!player) throw new Error("Player not found"); await pbAdmin.enrollFreeAgent(player.id, data.phone, data.tournamentId); logger.info('Player enrolled as free agent', { playerId: player.id, phone: data.phone }); }) ); export const unenrollFreeAgent = createServerFn() .inputValidator(z.object({ tournamentId: z.string() })) .middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware]) .handler(async ({ context, data }) => toServerResult(async () => { const userAuthId = context.userAuthId; const player = await pbAdmin.getPlayerByAuthId(userAuthId); if (!player) throw new Error("Player not found"); await pbAdmin.unenrollFreeAgent(player.id, data.tournamentId); logger.info('Player unenrolled as free agent', { playerId: player.id }); }) ); export const generateRandomTeams = createServerFn() .inputValidator(z.object({ tournamentId: z.string(), seed: z.number().optional() })) .middleware([superTokensAdminFunctionMiddleware]) .handler(async ({ data }) => toServerResult(async () => { const freeAgents = await pbAdmin.getFreeAgents(data.tournamentId); if (freeAgents.length < 2) { throw new Error("Need at least 2 players to create teams"); } if (freeAgents.length % 2 !== 0) { throw new Error("Need an even number of players to create teams"); } const uniqueFreeAgents = Array.from( new Map( freeAgents .filter(fa => fa.player?.id) .map(fa => [fa.player!.id, fa]) ).values() ); if (uniqueFreeAgents.length !== freeAgents.length) { logger.warn('Duplicate free agents detected', { freeAgentCount: freeAgents.length, uniquePlayerCount: uniqueFreeAgents.length }); } if (uniqueFreeAgents.length < 2) { throw new Error("Need at least 2 unique players to create teams"); } if (uniqueFreeAgents.length % 2 !== 0) { throw new Error("Need an even number of unique players to create teams"); } const playerIds = uniqueFreeAgents.map(fa => fa.player!.id); const allTeams = await pbAdmin.getTeamsWithFilter( playerIds.map(id => `players.id ?= "${id}"`).join(" || "), "players,tournaments" ); const invalidPairings = new Set(); const mostRecentRegionalPartners = new Map(); let mostRecentRegionalDate: Date | null = null; for (const team of allTeams) { const teamPlayers = (team.expand?.players || []) as any[]; if (teamPlayers.length !== 2) continue; const [p1, p2] = teamPlayers.map((p: any) => p.id).sort(); const pairKey = `${p1}|${p2}`; const teamTournaments = (team.expand?.tournaments || []) as any[]; const hasMainlineTournament = teamTournaments.some((t: any) => !t.regional); if (hasMainlineTournament) { invalidPairings.add(pairKey); } else if (team.private && teamTournaments.length > 0) { const regionalTournaments = teamTournaments.filter((t: any) => t.regional); for (const tournament of regionalTournaments) { const tournamentDate = new Date(tournament.start_time); if (!mostRecentRegionalDate || tournamentDate > mostRecentRegionalDate) { mostRecentRegionalDate = tournamentDate; } } } } if (mostRecentRegionalDate) { for (const team of allTeams) { if (!team.private) continue; const teamPlayers = (team.expand?.players || []) as any[]; if (teamPlayers.length !== 2) continue; const teamTournaments = (team.expand?.tournaments || []) as any[]; const regionalTournaments = teamTournaments.filter((t: any) => t.regional); for (const tournament of regionalTournaments) { const tournamentDate = new Date(tournament.start_time); if (tournamentDate.getTime() === mostRecentRegionalDate.getTime()) { const [p1Id, p2Id] = teamPlayers.map((p: any) => p.id); mostRecentRegionalPartners.set(p1Id, p2Id); mostRecentRegionalPartners.set(p2Id, p1Id); } } } } function canPairPlayers(p1Id: string, p2Id: string): boolean { const pairKey = [p1Id, p2Id].sort().join('|'); if (invalidPairings.has(pairKey)) return false; const p1LastPartner = mostRecentRegionalPartners.get(p1Id); if (p1LastPartner === p2Id) return false; return true; } const seed = data.seed || Math.floor(Math.random() * 1000000); function seededRandom(s: number) { const x = Math.sin(s++) * 10000; return x - Math.floor(x); } let currentSeed = seed; const shuffled = [...uniqueFreeAgents]; for (let i = shuffled.length - 1; i > 0; i--) { const j = Math.floor(seededRandom(currentSeed++) * (i + 1)); [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; } const assignments = []; const paired = new Set(); const MAX_ATTEMPTS = 1000; let attempts = 0; while (paired.size < shuffled.length && attempts < MAX_ATTEMPTS) { attempts++; for (let i = 0; i < shuffled.length; i++) { if (paired.has(shuffled[i].player!.id)) continue; for (let j = i + 1; j < shuffled.length; j++) { if (paired.has(shuffled[j].player!.id)) continue; const player1 = shuffled[i].player!; const player2 = shuffled[j].player!; if (canPairPlayers(player1.id, player2.id)) { const teamName = `${player1.first_name} And ${player2.first_name}`; assignments.push({ player1, player2, teamName }); paired.add(player1.id); paired.add(player2.id); break; } } } if (paired.size < shuffled.length) { currentSeed++; for (let i = shuffled.length - 1; i > 0; i--) { const j = Math.floor(seededRandom(currentSeed++) * (i + 1)); [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; } assignments.length = 0; paired.clear(); } } if (paired.size < shuffled.length) { throw new Error("Unable to create valid pairings with current restrictions. Please manually adjust enrollments."); } logger.info('Generated random team assignments with restrictions', { tournamentId: data.tournamentId, teamCount: assignments.length, seed, attempts }); return { assignments, seed }; }) ); export const confirmTeamAssignments = createServerFn() .inputValidator(z.object({ tournamentId: z.string(), assignments: z.array(z.object({ player1Id: z.string(), player2Id: z.string(), teamName: z.string() })) })) .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware]) .handler(async ({ data }) => toServerResult(async () => { const createdTeams = []; let reusedCount = 0; for (const assignment of data.assignments) { const existingTeams = await pbAdmin.getTeamsWithFilter( `private = true && players.id ?= "${assignment.player1Id}" && players.id ?= "${assignment.player2Id}"`, "players,tournaments" ); let teamToUse = null; for (const team of existingTeams) { const teamPlayers = (team.expand?.players || []) as any[]; if (teamPlayers.length !== 2) continue; const playerIds = teamPlayers.map((p: any) => p.id).sort(); const assignmentIds = [assignment.player1Id, assignment.player2Id].sort(); if (playerIds[0] !== assignmentIds[0] || playerIds[1] !== assignmentIds[1]) continue; const teamTournaments = (team.expand?.tournaments || []) as any[]; const hasMainlineTournament = teamTournaments.some((t: any) => !t.regional); if (!hasMainlineTournament) { teamToUse = team; break; } } if (teamToUse) { await pbAdmin.enrollTeam(data.tournamentId, teamToUse.id); createdTeams.push(teamToUse); reusedCount++; logger.info('Reusing existing regional team', { teamId: teamToUse.id, teamName: teamToUse.name }); } else { const team = await pbAdmin.createTeam({ name: assignment.teamName, players: [assignment.player1Id, assignment.player2Id], private: true }); await pbAdmin.enrollTeam(data.tournamentId, team.id); createdTeams.push(team); } } for (const assignment of data.assignments) { await pbAdmin.unenrollFreeAgent(assignment.player1Id, data.tournamentId); await pbAdmin.unenrollFreeAgent(assignment.player2Id, data.tournamentId); } logger.info('Confirmed team assignments', { tournamentId: data.tournamentId, teamCount: createdTeams.length, reusedCount, newCount: createdTeams.length - reusedCount }); return { teams: createdTeams }; }) ); async function calculateGroupStandings(groupId: string): Promise { const group = await pbAdmin.getGroup(groupId); if (!group) { throw new Error("Group not found"); } const matches = await pbAdmin.getMatchesByGroup(groupId); const completedMatches = matches.filter(m => m.status === "ended"); const standings = new Map(); for (const team of group.teams || []) { standings.set(team.id, { team, wins: 0, losses: 0, cups_for: 0, cups_against: 0, cup_differential: 0, rank: 0, }); } for (const match of completedMatches) { if (!match.home || !match.away) continue; const homeStanding = standings.get(match.home.id); const awayStanding = standings.get(match.away.id); if (homeStanding && awayStanding) { homeStanding.cups_for += match.home_cups; homeStanding.cups_against += match.away_cups; awayStanding.cups_for += match.away_cups; awayStanding.cups_against += match.home_cups; if (match.home_cups > match.away_cups) { homeStanding.wins++; awayStanding.losses++; } else { awayStanding.wins++; homeStanding.losses++; } } } for (const standing of standings.values()) { standing.cup_differential = standing.cups_for - standing.cups_against; } const h2hRecords = new Map>(); for (const match of completedMatches) { if (!match.home || !match.away) continue; if (!h2hRecords.has(match.home.id)) { h2hRecords.set(match.home.id, new Map()); } if (!h2hRecords.has(match.away.id)) { h2hRecords.set(match.away.id, new Map()); } const homeH2H = h2hRecords.get(match.home.id)!; const awayH2H = h2hRecords.get(match.away.id)!; if (!homeH2H.has(match.away.id)) { homeH2H.set(match.away.id, { wins: 0, cupDiff: 0 }); } if (!awayH2H.has(match.home.id)) { awayH2H.set(match.home.id, { wins: 0, cupDiff: 0 }); } const homeRecord = homeH2H.get(match.away.id)!; const awayRecord = awayH2H.get(match.home.id)!; const cupDiff = match.home_cups - match.away_cups; homeRecord.cupDiff += cupDiff; awayRecord.cupDiff -= cupDiff; if (match.home_cups > match.away_cups) { homeRecord.wins++; } else { awayRecord.wins++; } } const sortedStandings = Array.from(standings.values()).sort((a, b) => { if (b.wins !== a.wins) return b.wins - a.wins; if (b.cup_differential !== a.cup_differential) return b.cup_differential - a.cup_differential; if (b.cups_for !== a.cups_for) return b.cups_for - a.cups_for; const aH2H = h2hRecords.get(a.team.id); const bH2H = h2hRecords.get(b.team.id); if (aH2H && bH2H) { const aVsB = aH2H.get(b.team.id); const bVsA = bH2H.get(a.team.id); if (aVsB && bVsA) { if (aVsB.wins !== bVsA.wins) return bVsA.wins - aVsB.wins; if (aVsB.cupDiff !== -bVsA.cupDiff) return aVsB.cupDiff - (-bVsA.cupDiff); } } return a.team.id.localeCompare(b.team.id); }); sortedStandings.forEach((standing, index) => { standing.rank = index + 1; }); return sortedStandings; } export const getGroupStandings = createServerFn() .inputValidator(z.string()) .middleware([superTokensFunctionMiddleware]) .handler(async ({ data: groupId }) => toServerResult(() => calculateGroupStandings(groupId)) ); export const generateKnockoutBracket = createServerFn() .inputValidator(z.object({ tournamentId: z.string(), })) .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware]) .handler(async ({ data }) => toServerResult(async () => { logger.info('Generating knockout bracket', { tournamentId: data.tournamentId, }); const tournament = await pbAdmin.getTournament(data.tournamentId); if (!tournament) { throw new Error("Tournament not found"); } if (tournament.phase !== "group_stage") { throw new Error("Tournament must be in group_stage phase to generate knockout bracket"); } if (!tournament.group_config) { throw new Error("Tournament must have group_config"); } const groups = await pbAdmin.getGroupsByTournament(data.tournamentId); if (!groups || groups.length === 0) { throw new Error("No groups found for tournament"); } const qualifiedTeams: { teamId: string; groupOrder: number; rank: number }[] = []; for (const group of groups) { const standings = await calculateGroupStandings(group.id); const topTeams = standings.slice(0, tournament.group_config.advance_per_group); for (const standing of topTeams) { qualifiedTeams.push({ teamId: standing.team.id, groupOrder: group.order, rank: standing.rank, }); } } const orderedTeamIds: string[] = []; const maxRank = tournament.group_config.advance_per_group; const numGroups = tournament.group_config.num_groups; const teamsByGroup: string[][] = []; for (let g = 0; g < numGroups; g++) { teamsByGroup[g] = []; } for (const qualified of qualifiedTeams) { teamsByGroup[qualified.groupOrder][qualified.rank - 1] = qualified.teamId; } const totalTeams = numGroups * maxRank; for (let i = 0; i < totalTeams / 2; i++) { const group1 = i % numGroups; const rankIndex1 = Math.floor(i / numGroups); const group2 = (i + 1) % numGroups; const rankIndex2 = maxRank - 1 - rankIndex1; const team1 = teamsByGroup[group1]?.[rankIndex1]; const team2 = teamsByGroup[group2]?.[rankIndex2]; if (team1) orderedTeamIds.push(team1); if (team2) orderedTeamIds.push(team2); } const teamCount = orderedTeamIds.length; let bracketTemplate: any; if (Object.keys(brackets).includes(teamCount.toString())) { bracketTemplate = brackets[teamCount as keyof typeof brackets]; } else { bracketTemplate = generateSingleEliminationBracket(teamCount); } const seedToTeamId = new Map(); orderedTeamIds.forEach((teamId, index) => { seedToTeamId.set(index + 1, teamId); }); const matchInputs: MatchInput[] = []; let matchLid = 1000; bracketTemplate.winners.forEach((round: any[]) => { round.forEach((match: any) => { const matchInput: MatchInput = { lid: matchLid++, round: match.round, order: match.order || 0, reset: false, bye: match.bye || false, home_cups: 0, away_cups: 0, ot_count: 0, home_from_lid: match.home_from_lid === null ? -1 : (match.home_from_lid + 1000), away_from_lid: match.away_from_lid === null ? -1 : (match.away_from_lid + 1000), home_from_loser: false, away_from_loser: false, is_losers_bracket: false, match_type: "knockout", status: "tbd", tournament: data.tournamentId, }; if (match.home_seed) { const teamId = seedToTeamId.get(match.home_seed); if (teamId) { matchInput.home = teamId; matchInput.home_seed = match.home_seed; } } if (match.away_seed) { const teamId = seedToTeamId.get(match.away_seed); if (teamId) { matchInput.away = teamId; matchInput.away_seed = match.away_seed; } } if (matchInput.home && matchInput.away) { matchInput.status = "ready"; } matchInputs.push(matchInput); }); }); const createdMatches = await pbAdmin.createMatches(matchInputs); const existingMatchIds = tournament.matches?.map(m => m.id) || []; const newMatchIds = createdMatches.map(m => m.id); await pbAdmin.updateTournamentMatches(data.tournamentId, [...existingMatchIds, ...newMatchIds]); await pbAdmin.updateTournament(data.tournamentId, { phase: "knockout" }); logger.info('Knockout bracket generated', { tournamentId: data.tournamentId, matchCount: createdMatches.length, qualifiedTeamCount: qualifiedTeams.length }); return { tournament, matchCount: createdMatches.length, matches: createdMatches }; }) ); export const adminEnrollPlayer = createServerFn() .inputValidator(z.object({ playerId: z.string(), tournamentId: z.string() })) .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware]) .handler(async ({ data }) => toServerResult(async () => { await pbAdmin.enrollFreeAgent(data.playerId, "", data.tournamentId); logger.info('Admin enrolled player', { playerId: data.playerId, tournamentId: data.tournamentId }); }) ); export const adminUnenrollPlayer = createServerFn() .inputValidator(z.object({ playerId: z.string(), tournamentId: z.string() })) .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware]) .handler(async ({ data }) => toServerResult(async () => { await pbAdmin.unenrollFreeAgent(data.playerId, data.tournamentId); logger.info('Admin unenrolled player', { playerId: data.playerId, tournamentId: data.tournamentId }); }) ); export const generateGroupStage = createServerFn() .inputValidator(z.object({ tournamentId: z.string(), groupConfig: z.object({ num_groups: z.number(), teams_per_group: z.number(), advance_per_group: z.number(), matches_guaranteed: z.number(), seeding_method: z.enum(["random", "ranked"]), }), teamAssignments: z.array(z.object({ groupIndex: z.number(), groupName: z.string(), teamIds: z.array(z.string()) })), seed: z.number().optional() })) .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware]) .handler(async ({ data }) => toServerResult(async () => { logger.info('Generating group stage', { tournamentId: data.tournamentId, numGroups: data.groupConfig.num_groups, seed: data.seed }); const tournament = await pbAdmin.getTournament(data.tournamentId); if (!tournament) { throw new Error("Tournament not found"); } if (tournament.matches && tournament.matches.length > 0) { throw new Error("Tournament already has matches generated"); } await pbAdmin.deleteGroupsByTournament(data.tournamentId); const createdGroups = []; const groupStageMatches = []; for (const assignment of data.teamAssignments) { const group = await pbAdmin.createGroup({ tournament: data.tournamentId, name: assignment.groupName, order: assignment.groupIndex, teams: assignment.teamIds }); createdGroups.push(group); const teamIds = assignment.teamIds; for (let i = 0; i < teamIds.length; i++) { for (let j = i + 1; j < teamIds.length; j++) { groupStageMatches.push({ lid: -1, round: -1, order: groupStageMatches.length + 1, reset: false, bye: false, home: teamIds[i], away: teamIds[j], home_cups: 0, away_cups: 0, ot_count: 0, home_from_lid: -1, away_from_lid: -1, home_from_loser: false, away_from_loser: false, is_losers_bracket: false, match_type: "group_stage" as const, group: group.id, status: "ready" as const, tournament: data.tournamentId, }); } } } const knockoutTeamCount = data.groupConfig.num_groups * data.groupConfig.advance_per_group; let bracketTemplate: any; if (Object.keys(brackets).includes(knockoutTeamCount.toString())) { bracketTemplate = brackets[knockoutTeamCount as keyof typeof brackets]; } else { bracketTemplate = generateSingleEliminationBracket(knockoutTeamCount); } const knockoutMatches: any[] = []; bracketTemplate.winners.forEach((round: any[]) => { round.forEach((match: any) => { knockoutMatches.push({ lid: match.lid, round: match.round, order: match.order, reset: false, bye: match.bye || false, home_seed: match.home_seed, away_seed: match.away_seed, home_cups: 0, away_cups: 0, ot_count: 0, home_from_lid: match.home_from_lid !== null ? match.home_from_lid : -1, away_from_lid: match.away_from_lid !== null ? match.away_from_lid : -1, home_from_loser: false, away_from_loser: false, is_losers_bracket: false, match_type: "knockout" as const, status: "tbd" as const, tournament: data.tournamentId, }); }); }); const allMatches = [...groupStageMatches, ...knockoutMatches]; const createdMatches = await pbAdmin.createMatches(allMatches); const matchIds = createdMatches.map((match) => match.id); await pbAdmin.updateTournamentMatches(data.tournamentId, matchIds); await pbAdmin.updateTournament(data.tournamentId, { phase: "group_stage", group_config: data.groupConfig }); logger.info('Group stage and knockout bracket generated', { tournamentId: data.tournamentId, groupCount: createdGroups.length, groupMatchCount: groupStageMatches.length, knockoutMatchCount: knockoutMatches.length, totalMatchCount: createdMatches.length }); return { tournament, groups: createdGroups, matchCount: createdMatches.length, matches: createdMatches }; }) );