- activities indexes + client_events/client_errors/rollups/alerts collections - global function middleware via createStart; reads skipped on success - actor stash, denial dedup, control-flow error guards - api route logging, auth event overrides, beacon endpoint, web vitals - centralized admin role checks, shared skeletons, award-badges fix - extracted deriveRunState with tests
1090 lines
34 KiB
TypeScript
1090 lines
34 KiB
TypeScript
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<number, string>();
|
|
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<string, { teamId: string; wins: number; losses: number; cups_for: number; cups_against: number; cup_differential: number }>();
|
|
|
|
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<string, { teamId: string; wins: number; losses: number; cups_for: number; cups_against: number; cup_differential: number }>();
|
|
|
|
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<number, string>();
|
|
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<string, unknown> }
|
|
| { kind: "createMatch"; data: Record<string, unknown> }
|
|
| { 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<void> {
|
|
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<MatchInput>);
|
|
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<number, Match>();
|
|
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<Match | undefined>(
|
|
(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<string, any>);
|
|
|
|
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<string, any>);
|
|
|
|
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;
|
|
})
|
|
);
|