Files
flxn-app/src/features/matches/server.ts
2025-10-11 13:40:12 -05:00

382 lines
12 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 { 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<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,
});
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;
})
);
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,
});
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");
// winner -> where to send match winner to, loser same
const { winner, loser } = await pbAdmin.getChildMatches(matchId);
// reset match check
if (winner && winner.reset) {
const awayTeamWon = match.away === matchWinner;
if (!awayTeamWon) {
// Reset match is not necessary
logger.info("Deleting reset match", {
resetMatchId: winner.id,
currentMatchId: match.id,
reason: "not necessary",
});
await pbAdmin.deleteMatch(winner.id);
return match;
}
}
// advance bracket
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<string, any>);
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<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()
.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;
})
);