import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; import { pbAdmin } from "@/lib/pocketbase/client"; import { logger } from "@/lib/logger"; import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result"; import { superTokensFunctionMiddleware } from "@/utils/supertokens"; import { Tournament } from "@/features/tournaments/types"; import { MyPrediction, Prediction, PredictionLeaderboardEntry, PredictionsLeaderboard, } from "./types"; import { computePredictionScore, getPickableMatches, isPredictionComplete, isPredictionLocked, isTournamentPredictable, } from "./utils"; const getTournamentOrThrow = async (tournamentId: string): Promise => { const tournament = await pbAdmin.getTournament(tournamentId); if (!tournament) { throw new Error("Tournament not found"); } return tournament; }; export const getMyPrediction = createServerFn() .validator(z.string()) .middleware([superTokensFunctionMiddleware]) .handler(async ({ data: tournamentId, context }) => toServerResult(async (): Promise => { const tournament = await getTournamentOrThrow(tournamentId); const matches = tournament.matches || []; const player = await pbAdmin.getPlayerByAuthId(context.userAuthId); const prediction = player ? await pbAdmin.getPrediction(tournamentId, player.id) : null; return { prediction, locked: isPredictionLocked(matches), eligible: isTournamentPredictable(tournament), }; }) ); const submitPredictionSchema = z.object({ tournamentId: z.string(), picks: z.record(z.string(), z.string()), }); export const submitPrediction = createServerFn() .validator(submitPredictionSchema) .middleware([superTokensFunctionMiddleware]) .handler(async ({ data: { tournamentId, picks }, context }) => toServerResult(async (): Promise => { const player = await pbAdmin.getPlayerByAuthId(context.userAuthId); if (!player) { throw new Error("Player not found"); } const tournament = await getTournamentOrThrow(tournamentId); const matches = tournament.matches || []; if (!isTournamentPredictable(tournament)) { throw new Error("Predictions are not available for this tournament"); } if (isPredictionLocked(matches)) { throw new Error("Predictions are locked — the tournament has started"); } const pickableLids = new Set( getPickableMatches(matches, picks).map((match) => String(match.lid)) ); const pickKeys = Object.keys(picks); if ( pickKeys.length !== pickableLids.size || pickKeys.some((lid) => !pickableLids.has(lid)) ) { throw new Error("Prediction must include a pick for every match"); } if (!isPredictionComplete(matches, picks)) { throw new Error("Prediction contains invalid picks"); } const prediction = await pbAdmin.upsertPrediction( tournamentId, player.id, picks ); logger.info("Prediction submitted", { tournamentId, playerId: player.id, pickCount: pickKeys.length, }); return prediction; }) ); export const getPredictionsLeaderboard = createServerFn() .validator(z.string()) .middleware([superTokensFunctionMiddleware]) .handler(async ({ data: tournamentId }) => toServerResult(async (): Promise => { const tournament = await getTournamentOrThrow(tournamentId); const matches = tournament.matches || []; const locked = isPredictionLocked(matches); const predictions = await pbAdmin.getPredictionsForTournament(tournamentId); const submitters = predictions.map((prediction) => prediction.player); if (!locked) { return { locked, count: predictions.length, entries: [], submitters }; } const entries: PredictionLeaderboardEntry[] = predictions.map( (prediction) => { const score = computePredictionScore(matches, prediction.picks); const championPick = tournament.teams?.find( (team) => team.id === score.predictedChampionId ); return { player: prediction.player, points: score.points, correct: score.correct, total: score.total, championPick, championCorrect: !!score.predictedChampionId && score.predictedChampionId === tournament.first_place?.id, updated: prediction.updated, }; } ); entries.sort( (a, b) => b.points - a.points || Number(b.championCorrect) - Number(a.championCorrect) || a.updated.localeCompare(b.updated) || (a.player.first_name ?? "").localeCompare(b.player.first_name ?? "") ); return { locked, count: entries.length, entries, submitters }; }) ); const playerPredictionSchema = z.object({ tournamentId: z.string(), playerId: z.string(), }); export const getPlayerPrediction = createServerFn() .validator(playerPredictionSchema) .middleware([superTokensFunctionMiddleware]) .handler(async ({ data: { tournamentId, playerId }, context }) => toServerResult(async (): Promise => { const tournament = await getTournamentOrThrow(tournamentId); if (!isPredictionLocked(tournament.matches || [])) { const me = await pbAdmin.getPlayerByAuthId(context.userAuthId); if (me?.id !== playerId) { throw new Error("Predictions are private until the tournament starts"); } } return pbAdmin.getPrediction(tournamentId, playerId); }) );