Files
flxn-app/src/features/tournaments/server.ts
2026-02-25 19:54:51 -06:00

516 lines
17 KiB
TypeScript

import { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } from "@/utils/supertokens";
import { createServerFn } from "@tanstack/react-start";
import { pbAdmin } from "@/lib/pocketbase/client";
import { tournamentInputSchema } 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";
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<string>();
const mostRecentRegionalPartners = new Map<string, string>();
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<string>();
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 };
})
);
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 allMatches = [];
let matchLid = 1;
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++) {
allMatches.push({
lid: matchLid++,
round: -1,
order: allMatches.length,
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 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 generated', {
tournamentId: data.tournamentId,
groupCount: createdGroups.length,
matchCount: createdMatches.length
});
return {
tournament,
groups: createdGroups,
matchCount: createdMatches.length,
matches: createdMatches
};
})
);