import { superTokensAdminFunctionMiddleware } from "@/utils/supertokens"; import { createServerFn } from "@tanstack/react-start"; import { pbAdmin } from "@/lib/pocketbase/client"; import { logger } from "@/lib/logger"; import { z } from "zod"; import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result"; import brackets from "@/features/bracket/utils"; import { Match, MatchInput } from "@/features/matches/types"; import { serverEvents } from "@/lib/events/emitter"; import { superTokensFunctionMiddleware } from "@/utils/supertokens"; import { PlayerInfo } from "../players/types"; import { serverFnLoggingMiddleware } from "@/utils/activities"; const orderedTeamsSchema = z.object({ tournamentId: z.string(), orderedTeamIds: z.array(z.string()), }); export const generateTournamentBracket = createServerFn() .inputValidator(orderedTeamsSchema) .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware]) .handler(async ({ data: { tournamentId, orderedTeamIds } }) => toServerResult(async () => { logger.info("Generating tournament bracket", { tournamentId, teamCount: orderedTeamIds.length, }); const tournament = await pbAdmin.getTournament(tournamentId); if (!tournament) { throw new Error("Tournament not found"); } if (tournament.matches && tournament.matches.length > 0) { throw new Error("Tournament already has matches generated"); } const teamCount = orderedTeamIds.length; if (!Object.keys(brackets).includes(teamCount.toString())) { throw new Error(`Bracket not available for ${teamCount} teams`); } const bracketTemplate = brackets[ teamCount as keyof typeof brackets ] as any; const seedToTeamId = new Map(); orderedTeamIds.forEach((teamId, index) => { seedToTeamId.set(index + 1, teamId); }); const matchInputs: MatchInput[] = []; bracketTemplate.winners.forEach((round: any[]) => { round.forEach((match: any) => { const matchInput: MatchInput = { lid: match.lid, round: match.round, order: match.order || 0, reset: match.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, away_from_lid: match.away_from_lid === null ? -1 : match.away_from_lid, home_from_loser: match.home_from_loser || false, away_from_loser: match.away_from_loser || false, is_losers_bracket: false, status: "tbd", tournament: 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); }); }); bracketTemplate.losers.forEach((round: any[]) => { round.forEach((match: any) => { const matchInput: MatchInput = { lid: match.lid, round: match.round, order: match.order || 0, reset: match.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, away_from_lid: match.away_from_lid === null ? -1 : match.away_from_lid, home_from_loser: match.home_from_loser || false, away_from_loser: match.away_from_loser || false, is_losers_bracket: true, status: "tbd", tournament: tournamentId, }; matchInputs.push(matchInput); }); }); const createdMatches = await pbAdmin.createMatches(matchInputs); const matchIds = createdMatches.map((match) => match.id); await pbAdmin.updateTournamentMatches(tournamentId, matchIds); logger.info("Tournament bracket generated", { tournamentId, matchCount: createdMatches.length, }); return { tournament, matchCount: createdMatches.length, matches: createdMatches, }; }) ); export const startMatch = createServerFn() .inputValidator(z.string()) .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware]) .handler(async ({ data }) => toServerResult(async () => { logger.info("Starting match", data); let match = await pbAdmin.getMatch(data); if (!match) { throw new Error("Match not found"); } match = await pbAdmin.updateMatch(data, { start_time: new Date().toISOString(), status: "started", }); serverEvents.emit("match", { type: "match", matchId: match.id, tournamentId: match.tournament.id }); return match; }) ); export const populateKnockoutBracket = createServerFn() .inputValidator(z.object({ tournamentId: z.string(), })) .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware]) .handler(async ({ data: { tournamentId } }) => toServerResult(async () => { const tournament = await pbAdmin.getTournament(tournamentId); if (!tournament) { throw new Error("Tournament not found"); } if (!tournament.group_config) { throw new Error("Tournament must have group_config"); } return await populateKnockoutBracketInternal(tournamentId, tournament.group_config); }) ); async function populateKnockoutBracketInternal(tournamentId: string, groupConfig: { num_groups: number; advance_per_group: number }) { logger.info('Populating knockout bracket', { tournamentId }); const groups = await pbAdmin.getGroupsByTournament(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) { logger.info('Processing group', { groupId: group.id, groupOrder: group.order, teamsCount: group.teams?.length, teams: group.teams }); const groupMatches = await pbAdmin.getMatchesByGroup(group.id); const completedMatches = groupMatches.filter(m => m.status === "ended"); const standings = new Map(); for (const team of group.teams || []) { // group.teams can be either team objects or just team ID strings const teamId = typeof team === 'string' ? team : team.id; standings.set(teamId, { teamId, wins: 0, losses: 0, cups_for: 0, cups_against: 0, cup_differential: 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++; } homeStanding.cup_differential = homeStanding.cups_for - homeStanding.cups_against; awayStanding.cup_differential = awayStanding.cups_for - awayStanding.cups_against; } } 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; return b.cups_for - a.cups_for; }); const topTeams = sortedStandings.slice(0, groupConfig.advance_per_group); logger.info('Top teams from group', { groupId: group.id, topTeams: topTeams.map(t => ({ teamId: t.teamId, wins: t.wins, cupDiff: t.cup_differential })) }); topTeams.forEach((standing, index) => { qualifiedTeams.push({ teamId: standing.teamId, groupOrder: group.order, rank: index + 1, }); }); } logger.info('Qualified teams', { qualifiedTeams }); const orderedTeamIds: string[] = []; const maxRank = groupConfig.advance_per_group; for (let rank = 1; rank <= maxRank; rank++) { const teamsAtRank = qualifiedTeams .filter(t => t.rank === rank) .sort((a, b) => a.groupOrder - b.groupOrder); orderedTeamIds.push(...teamsAtRank.map(t => t.teamId)); } logger.info('Ordered team IDs', { orderedTeamIds }); const tournament = await pbAdmin.getTournament(tournamentId); const knockoutMatches = (tournament.matches || []) .filter((m: Match) => m.round >= 0 && m.lid >= 0) .sort((a: Match, b: Match) => a.lid - b.lid); const seedToTeamId = new Map(); orderedTeamIds.forEach((teamId, index) => { seedToTeamId.set(index + 1, teamId); }); logger.info('Seed to team mapping', { seedToTeamId: Array.from(seedToTeamId.entries()), orderedTeamIds }); let updatedCount = 0; for (const match of knockoutMatches) { if (match.round === 0) { const updates: any = {}; if (match.home_seed) { const teamId = seedToTeamId.get(match.home_seed); logger.info('Looking up home seed', { matchId: match.id, home_seed: match.home_seed, teamId }); if (teamId) { updates.home = teamId; } } if (match.away_seed) { const teamId = seedToTeamId.get(match.away_seed); logger.info('Looking up away seed', { matchId: match.id, away_seed: match.away_seed, teamId }); if (teamId) { updates.away = teamId; } } if (updates.home && updates.away) { updates.status = "ready"; } else if (updates.home || updates.away) { updates.status = "tbd"; } if (Object.keys(updates).length > 0) { logger.info('Updating match', { matchId: match.id, updates }); await pbAdmin.updateMatch(match.id, updates); updatedCount++; } } } logger.info('Updated matches', { updatedCount, totalKnockoutMatches: knockoutMatches.length }); await pbAdmin.updateTournament(tournamentId, { phase: "knockout" }); logger.info('Knockout bracket populated successfully', { tournamentId }); } const endMatchSchema = z.object({ matchId: z.string(), home_cups: z.number(), away_cups: z.number(), ot_count: z.number(), }); export const endMatch = createServerFn() .inputValidator(endMatchSchema) .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware]) .handler(async ({ data: { matchId, home_cups, away_cups, ot_count } }) => toServerResult(async () => { logger.info("Ending match", matchId); let match = await pbAdmin.getMatch(matchId); if (!match) { throw new Error("Match not found"); } match = await pbAdmin.updateMatch(matchId, { end_time: new Date().toISOString(), status: "ended", home_cups, away_cups, ot_count, }); if (match.lid === -1) { serverEvents.emit("match", { type: "match", matchId: match.id, tournamentId: match.tournament.id }); return match; } const matchWinner = home_cups > away_cups ? match.home : match.away; const matchLoser = home_cups < away_cups ? match.home : match.away; if (!matchWinner || !matchLoser) throw new Error("Something went wrong"); const { winner, loser } = await pbAdmin.getChildMatches(matchId); if (winner && winner.reset) { const awayTeamWon = match.away === matchWinner; if (!awayTeamWon) { logger.info("Deleting reset match", { resetMatchId: winner.id, currentMatchId: match.id, reason: "not necessary", }); await pbAdmin.deleteMatch(winner.id); return match; } } if (winner) { await pbAdmin.updateMatch(winner.id, { [winner.home_from_lid === match.lid ? "home" : "away"]: matchWinner.id, status: (winner.home_from_lid === match.lid && winner.away) || (winner.away_from_lid === match.lid && winner.home) ? "ready" : "tbd", }); } if (loser) { await pbAdmin.updateMatch(loser.id, { [loser.home_from_lid === match.lid ? "home" : "away"]: matchLoser.id, status: (loser.home_from_lid === match.lid && loser.away) || (loser.away_from_lid === match.lid && loser.home) ? "ready" : "tbd", }); } serverEvents.emit("match", { type: "match", matchId: match.id, tournamentId: match.tournament.id }); return match; }) ); const toggleReactionSchema = z.object({ matchId: z.string(), emoji: z.string(), }); export const toggleMatchReaction = createServerFn() .inputValidator(toggleReactionSchema) .middleware([superTokensFunctionMiddleware]) .handler(async ({ data: { matchId, emoji }, context }) => toServerResult(async () => { const user = await pbAdmin.getPlayerByAuthId(context.userAuthId); const userId = user?.id; if (!userId) return; const match = await pbAdmin.getMatch(matchId); if (!match) { throw new Error("Match not found"); } const existingReaction = await pbAdmin.getUserReaction(matchId, userId, emoji); if (existingReaction) { await pbAdmin.deleteReaction(existingReaction.id); logger.info("Removed reaction", { matchId, emoji, userId }); } else { await pbAdmin.createReaction(matchId, userId, emoji); logger.info("Added reaction", { matchId, emoji, userId }); } const all = await pbAdmin.getReactionsForMatch(matchId); const reactionsByEmoji = all.reduce((acc, reaction) => { const emoji = reaction.emoji; if (!acc[emoji]) { acc[emoji] = { emoji, count: 0, players: [], }; } acc[emoji].count++; acc[emoji].players.push({ id: reaction?.player?.id, first_name: reaction.player?.first_name, last_name: reaction.player?.last_name, }); return acc; }, {} as Record); const reactions = Object.values(reactionsByEmoji); serverEvents.emit("reaction", { type: "reaction", matchId, reactions, }); return reactions as Reaction[] }) ); export interface Reaction { emoji: string; count: number; players: PlayerInfo[]; } export const getMatchReactions = createServerFn() .inputValidator(z.string()) .middleware([superTokensFunctionMiddleware]) .handler(async ({ data: matchId, context }) => toServerResult(async () => { const match = await pbAdmin.getMatch(matchId); if (!match) { throw new Error("Match not found"); } const all = await pbAdmin.getReactionsForMatch(matchId); const reactionsByEmoji = all.reduce((acc, reaction) => { const emoji = reaction.emoji; if (!acc[emoji]) { acc[emoji] = { emoji, count: 0, players: [], }; } acc[emoji].count++; acc[emoji].players.push({ id: reaction?.player?.id, first_name: reaction.player?.first_name, last_name: reaction.player?.last_name, }); return acc; }, {} as Record); const reactions = Object.values(reactionsByEmoji); return reactions as Reaction[] }) ); const matchesBetweenPlayersSchema = z.object({ player1Id: z.string(), player2Id: z.string(), }); export const getMatchesBetweenPlayers = createServerFn() .inputValidator(matchesBetweenPlayersSchema) .middleware([superTokensFunctionMiddleware]) .handler(async ({ data: { player1Id, player2Id } }) => toServerResult(async () => { logger.info("Getting matches between players", { player1Id, player2Id }); const matches = await pbAdmin.getMatchesBetweenPlayers(player1Id, player2Id); return matches; }) ); const matchesBetweenTeamsSchema = z.object({ team1Id: z.string(), team2Id: z.string(), }); export const getMatchesBetweenTeams = createServerFn() .inputValidator(matchesBetweenTeamsSchema) .middleware([superTokensFunctionMiddleware]) .handler(async ({ data: { team1Id, team2Id } }) => toServerResult(async () => { logger.info("Getting matches between teams", { team1Id, team2Id }); const matches = await pbAdmin.getMatchesBetweenTeams(team1Id, team2Id); return matches; }) );