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 { emitServerEvent } from "@/lib/events/emitter"; import { superTokensFunctionMiddleware } from "@/utils/supertokens"; import { PlayerInfo } from "../players/types"; const orderedTeamsSchema = z.object({ tournamentId: z.string(), orderedTeamIds: z.array(z.string()), }); export const generateTournamentBracket = createServerFn() .validator(orderedTeamsSchema) .middleware([superTokensAdminFunctionMiddleware]) .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, }); emitServerEvent({ type: "tournament", tournamentId }); return { tournament, matchCount: createdMatches.length, matches: createdMatches, }; }) ); export const startMatch = createServerFn() .validator(z.string()) .middleware([superTokensAdminFunctionMiddleware]) .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", }); emitServerEvent({ type: "match", matchId: match.id, tournamentId: match.tournament.id }); return match; }) ); export const populateKnockoutBracket = createServerFn() .validator(z.object({ tournamentId: z.string(), })) .middleware([superTokensAdminFunctionMiddleware]) .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"); } const result = await populateKnockoutBracketInternal(tournamentId, tournament.group_config); emitServerEvent({ type: "tournament", tournamentId }); return result; }) ); 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 || []) { 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; const numGroups = groupConfig.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 totalQualifiedTeams = numGroups * maxRank; for (let i = 0; i < totalQualifiedTeams / 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 knockoutTeamCount = orderedTeamIds.length; const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(knockoutTeamCount))); const wildcardsNeeded = nextPowerOf2 - knockoutTeamCount; if (wildcardsNeeded > 0) { logger.info('Wildcards needed', { knockoutTeamCount, nextPowerOf2, wildcardsNeeded }); const allNonQualifiedTeams: Array<{ teamId: string; wins: number; losses: number; cups_for: number; cups_against: number; cup_differential: number }> = []; const qualifiedTeamIds = new Set(qualifiedTeams.map(t => t.teamId)); for (const group of groups) { 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 || []) { const teamId = typeof team === 'string' ? team : team.id; if (qualifiedTeamIds.has(teamId)) continue; 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) { homeStanding.cups_for += match.home_cups; homeStanding.cups_against += match.away_cups; homeStanding.cup_differential = homeStanding.cups_for - homeStanding.cups_against; if (match.home_cups > match.away_cups) { homeStanding.wins++; } else { homeStanding.losses++; } } if (awayStanding) { awayStanding.cups_for += match.away_cups; awayStanding.cups_against += match.home_cups; awayStanding.cup_differential = awayStanding.cups_for - awayStanding.cups_against; if (match.away_cups > match.home_cups) { awayStanding.wins++; } else { awayStanding.losses++; } } } allNonQualifiedTeams.push(...Array.from(standings.values())); } allNonQualifiedTeams.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; return a.teamId.localeCompare(b.teamId); }); const wildcardTeams = allNonQualifiedTeams.slice(0, wildcardsNeeded); const wildcardTeamIds = wildcardTeams.map(t => t.teamId); orderedTeamIds.push(...wildcardTeamIds); logger.info('Added wildcard teams', { wildcardTeams: wildcardTeams.map(t => ({ teamId: t.teamId, wins: t.wins, cupDiff: t.cup_differential, cupsFor: t.cups_for })) }); } logger.info('Ordered team IDs (with wildcards)', { orderedTeamIds, totalTeams: orderedTeamIds.length }); 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 teamId = (t: Match["home"] | string | undefined): string | undefined => !t ? undefined : typeof t === "string" ? t : t.id; const winnerId = (m: Match): string | undefined => m.home_cups > m.away_cups ? teamId(m.home) : teamId(m.away); const loserId = (m: Match): string | undefined => m.home_cups > m.away_cups ? teamId(m.away) : teamId(m.home); function assertValidScore(home_cups: number, away_cups: number, ot_count: number) { if (home_cups === away_cups) throw new Error("A match cannot end in a tie"); if (ot_count > 0) return; if (home_cups !== 10 && away_cups !== 10) throw new Error("At least one team must have 10 cups"); if (home_cups === 10 && away_cups === 10) throw new Error("Both teams cannot have 10 cups"); } const CLEARED_RESULT = { home_cups: 0, away_cups: 0, ot_count: 0, start_time: "", end_time: "", reported_home_cups: null, reported_away_cups: null, reported_ot_count: null, reported_by_team: null, reported_by_player: null, } as const; function generateRecordId(): string { const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"; let id = ""; for (let i = 0; i < 15; i++) id += alphabet[Math.floor(Math.random() * alphabet.length)]; return id; } type BracketOp = | { kind: "updateMatch"; id: string; data: Record } | { kind: "createMatch"; data: Record } | { kind: "deleteMatch"; id: string } | { kind: "updateTournamentMatches"; tournamentId: string; matchIds: string[] }; function isBatchApiUnavailable(error: unknown): boolean { if (!error || typeof error !== "object") return false; const e = error as { status?: unknown; url?: unknown }; const url = typeof e.url === "string" ? e.url : ""; if (!url.includes("/api/batch")) return false; return e.status === 403 || e.status === 404; } async function commitBracketWrites(ops: BracketOp[]): Promise { const batch = pbAdmin.createBatch(); for (const op of ops) { switch (op.kind) { case "updateMatch": batch.collection("matches").update(op.id, op.data); break; case "createMatch": batch.collection("matches").create(op.data); break; case "deleteMatch": batch.collection("matches").delete(op.id); break; case "updateTournamentMatches": batch .collection("tournaments") .update(op.tournamentId, { matches: op.matchIds }); break; } } try { await batch.send(); return; } catch (error) { if (!isBatchApiUnavailable(error)) throw error; logger.warn( "PocketBase batch API is disabled; finalizing match with sequential writes (reduced atomicity). Enable Settings > Application > batch API to restore transactional bracket updates.", ); } for (const op of ops) { switch (op.kind) { case "updateMatch": await pbAdmin.updateMatch(op.id, op.data as Partial); break; case "createMatch": await pbAdmin.createMatch(op.data as unknown as MatchInput); break; case "deleteMatch": await pbAdmin.deleteMatch(op.id); break; case "updateTournamentMatches": await pbAdmin.updateTournamentMatches(op.tournamentId, op.matchIds); break; } } } async function rederiveBracket( tournamentId: string, sourceId: string, score: { home_cups: number; away_cups: number; ot_count: number } ): Promise<{ source: Match; downstreamReset: boolean }> { const all = await pbAdmin.getMatchesByTournament(tournamentId); const knockout = all.filter((m) => m.lid >= 0); const byLid = new Map(); for (const m of knockout) byLid.set(m.lid, m); const original = all.find((m) => m.id === sourceId); if (!original) throw new Error("Match not found"); const ops: BracketOp[] = []; let warn = false; const nowIso = new Date().toISOString(); ops.push({ kind: "updateMatch", id: sourceId, data: { end_time: nowIso, status: "ended", home_cups: score.home_cups, away_cups: score.away_cups, ot_count: score.ot_count, reported_home_cups: null, reported_away_cups: null, reported_ot_count: null, reported_by_team: null, reported_by_player: null, }, }); const source: Match = { ...original, status: "ended", home_cups: score.home_cups, away_cups: score.away_cups, ot_count: score.ot_count, end_time: nowIso, reported_home_cups: undefined, reported_away_cups: undefined, reported_ot_count: undefined, reported_by_team: undefined, reported_by_player: undefined, }; byLid.set(source.lid, source); const resolveFeeder = (lid: number, fromLoser: boolean): string | undefined => { const f = byLid.get(lid); if (!f || f.bye || f.status !== "ended") return undefined; return fromLoser ? loserId(f) : winnerId(f); }; const hasLosers = knockout.some((m) => m.is_losers_bracket); const resetMatch = knockout.find((m) => m.reset); const grandFinal = hasLosers ? knockout .filter((m) => !m.reset && !m.bye) .reduce( (hi, cur) => (!hi || cur.lid > hi.lid ? cur : hi), undefined ) : undefined; const ordered = knockout .filter((m) => !m.bye && !m.reset) .sort((a, b) => a.lid - b.lid); for (const m of ordered) { if (m.id === sourceId) continue; const homeFed = m.home_from_lid >= 0; const awayFed = m.away_from_lid >= 0; if (!homeFed && !awayFed) continue; const curHome = teamId(m.home); const curAway = teamId(m.away); const expHome = homeFed ? resolveFeeder(m.home_from_lid, m.home_from_loser) : curHome; const expAway = awayFed ? resolveFeeder(m.away_from_lid, m.away_from_loser) : curAway; const expStatus = expHome && expAway ? "ready" : "tbd"; const played = m.status === "started" || m.status === "ended"; const changed = (homeFed && expHome !== curHome) || (awayFed && expAway !== curAway); if (played && changed) { ops.push({ kind: "updateMatch", id: m.id, data: { home: expHome ?? null, away: expAway ?? null, status: expStatus, ...CLEARED_RESULT, }, }); byLid.set(m.lid, { ...m, status: expStatus }); warn = true; } else if (!played && (changed || m.status !== expStatus)) { ops.push({ kind: "updateMatch", id: m.id, data: { home: expHome ?? null, away: expAway ?? null, status: expStatus, }, }); byLid.set(m.lid, { ...m, status: expStatus }); } } if (grandFinal) { const gf = byLid.get(grandFinal.lid)!; const gfEnded = gf.status === "ended"; const resetNeeded = gfEnded && winnerId(gf) === teamId(gf.away); if (resetNeeded) { const expHome = winnerId(gf); const expAway = loserId(gf); if (resetMatch && resetMatch.id !== sourceId) { const played = resetMatch.status === "started" || resetMatch.status === "ended"; const changed = teamId(resetMatch.home) !== expHome || teamId(resetMatch.away) !== expAway; if (played && changed) { ops.push({ kind: "updateMatch", id: resetMatch.id, data: { home: expHome ?? null, away: expAway ?? null, status: "ready", ...CLEARED_RESULT, }, }); warn = true; } else if (!played && (changed || resetMatch.status !== "ready")) { ops.push({ kind: "updateMatch", id: resetMatch.id, data: { home: expHome ?? null, away: expAway ?? null, status: "ready", }, }); } } else if (!resetMatch) { const newId = generateRecordId(); ops.push({ kind: "createMatch", data: { id: newId, lid: gf.lid + 1, order: gf.order + 1, round: gf.round + 1, reset: true, bye: false, home_cups: 0, away_cups: 0, ot_count: 0, home_from_lid: gf.lid, away_from_lid: gf.lid, home_from_loser: false, away_from_loser: true, is_losers_bracket: false, status: "ready", tournament: tournamentId, home: expHome ?? undefined, away: expAway ?? undefined, }, }); ops.push({ kind: "updateTournamentMatches", tournamentId, matchIds: [...all.map((m) => m.id), newId], }); } } else if (resetMatch && resetMatch.id !== sourceId) { if (resetMatch.status === "started" || resetMatch.status === "ended") { warn = true; } ops.push({ kind: "deleteMatch", id: resetMatch.id }); } } await commitBracketWrites(ops); return { source, downstreamReset: warn }; } async function finalizeMatch( matchId: string, { home_cups, away_cups, ot_count }: { home_cups: number; away_cups: number; ot_count: number } ): Promise<{ match: Match; downstreamReset: boolean; groupEditAfterKnockout: boolean }> { assertValidScore(home_cups, away_cups, ot_count); const existing = await pbAdmin.getMatch(matchId); if (!existing) throw new Error("Match not found"); const tournamentId = existing.tournament.id; if (existing.lid === -1) { const source = await pbAdmin.updateMatch(matchId, { end_time: new Date().toISOString(), status: "ended", home_cups, away_cups, ot_count, reported_home_cups: null, reported_away_cups: null, reported_ot_count: null, reported_by_team: null, reported_by_player: null, }); const all = await pbAdmin.getMatchesByTournament(tournamentId); const groupEditAfterKnockout = all.some( (m) => m.round >= 0 && (teamId(m.home) || teamId(m.away)) ); emitServerEvent({ type: "match", matchId: source.id, tournamentId }); return { match: source, downstreamReset: false, groupEditAfterKnockout }; } const { source, downstreamReset } = await rederiveBracket(tournamentId, matchId, { home_cups, away_cups, ot_count, }); emitServerEvent({ type: "match", matchId: source.id, tournamentId }); return { match: source, downstreamReset, groupEditAfterKnockout: false }; } const endMatchSchema = z.object({ matchId: z.string(), home_cups: z.number(), away_cups: z.number(), ot_count: z.number(), }); export const endMatch = createServerFn() .validator(endMatchSchema) .middleware([superTokensAdminFunctionMiddleware]) .handler(async ({ data: { matchId, home_cups, away_cups, ot_count } }) => toServerResult(async () => { logger.info("Ending match", matchId); const match = await pbAdmin.getMatch(matchId); if (!match) { throw new Error("Match not found"); } return finalizeMatch(matchId, { home_cups, away_cups, ot_count }); }) ); const teamPlayerIds = (team: Match["home"]): string[] => team && typeof team !== "string" ? (team.players ?? []).map((p) => p.id) : []; const reportScoreSchema = z.object({ matchId: z.string(), home_cups: z.number(), away_cups: z.number(), ot_count: z.number(), }); export const reportMatchScore = createServerFn() .validator(reportScoreSchema) .middleware([superTokensFunctionMiddleware]) .handler(async ({ data: { matchId, home_cups, away_cups, ot_count }, context }) => toServerResult(async () => { const player = await pbAdmin.getPlayerByAuthId(context.userAuthId); if (!player?.id) throw new Error("Player not found"); const match = await pbAdmin.getMatch(matchId); if (!match) throw new Error("Match not found"); if (match.status !== "started") throw new Error("Scores can only be reported once the match has started"); const onHome = teamPlayerIds(match.home).includes(player.id); const onAway = teamPlayerIds(match.away).includes(player.id); if (!onHome && !onAway) throw new Error("You are not a player in this match"); if (onHome && onAway) throw new Error( "You are on both teams in this match and cannot report or confirm its score" ); assertValidScore(home_cups, away_cups, ot_count); const callerTeamId = onHome ? teamId(match.home) : teamId(match.away); const hasPending = match.reported_by_team != null && match.reported_home_cups != null && match.reported_away_cups != null; const fromOppositeTeam = hasPending && match.reported_by_team !== callerTeamId; const identical = hasPending && match.reported_home_cups === home_cups && match.reported_away_cups === away_cups && (match.reported_ot_count ?? 0) === ot_count; if (fromOppositeTeam && identical) { const result = await finalizeMatch(matchId, { home_cups, away_cups, ot_count }); return { success: true as const, finalized: true as const, downstreamReset: result.downstreamReset, groupEditAfterKnockout: result.groupEditAfterKnockout, }; } await pbAdmin.updateMatch(matchId, { reported_home_cups: home_cups, reported_away_cups: away_cups, reported_ot_count: ot_count, reported_by_team: callerTeamId, reported_by_player: player.id, }); emitServerEvent({ type: "match", matchId, tournamentId: match.tournament.id }); return { success: true as const, finalized: false as const }; }) ); const matchIdSchema = z.object({ matchId: z.string() }); export const confirmMatchScore = createServerFn() .validator(matchIdSchema) .middleware([superTokensFunctionMiddleware]) .handler(async ({ data: { matchId }, context }) => toServerResult(async () => { const player = await pbAdmin.getPlayerByAuthId(context.userAuthId); if (!player?.id) throw new Error("Player not found"); const match = await pbAdmin.getMatch(matchId); if (!match) throw new Error("Match not found"); if (match.status !== "started") throw new Error("Match is not in progress"); if (!match.reported_by_team) throw new Error("No score has been reported yet"); if (match.reported_home_cups == null || match.reported_away_cups == null) throw new Error("The reported score is incomplete"); const onHome = teamPlayerIds(match.home).includes(player.id); const onAway = teamPlayerIds(match.away).includes(player.id); if (onHome && onAway) throw new Error( "You are on both teams in this match and cannot report or confirm its score" ); const homeId = teamId(match.home); const reportedByHome = match.reported_by_team === homeId; const opposingTeam = reportedByHome ? match.away : match.home; if (!teamPlayerIds(opposingTeam).includes(player.id)) throw new Error("Only a player on the other team can confirm this score"); return finalizeMatch(matchId, { home_cups: match.reported_home_cups, away_cups: match.reported_away_cups, ot_count: match.reported_ot_count ?? 0, }); }) ); export const clearMatchReport = createServerFn() .validator(matchIdSchema) .middleware([superTokensFunctionMiddleware]) .handler(async ({ data: { matchId }, context }) => toServerResult(async () => { const player = await pbAdmin.getPlayerByAuthId(context.userAuthId); if (!player?.id) throw new Error("Player not found"); const match = await pbAdmin.getMatch(matchId); if (!match) throw new Error("Match not found"); const onHome = teamPlayerIds(match.home).includes(player.id); const onAway = teamPlayerIds(match.away).includes(player.id); if (!onHome && !onAway) throw new Error("You are not a player in this match"); await pbAdmin.updateMatch(matchId, { reported_home_cups: null, reported_away_cups: null, reported_ot_count: null, reported_by_team: null, reported_by_player: null, }); emitServerEvent({ type: "match", matchId, tournamentId: match.tournament.id }); return { success: true }; }) ); const toggleReactionSchema = z.object({ matchId: z.string(), emoji: z.string(), }); export const toggleMatchReaction = createServerFn() .validator(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); emitServerEvent({ type: "reaction", matchId, reactions, }); return reactions as Reaction[] }) ); export interface Reaction { emoji: string; count: number; players: PlayerInfo[]; } export const getMatchReactions = createServerFn() .validator(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() .validator(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() .validator(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; }) );