Files
flxn-app/src/features/predictions/server.ts
T
kyle e2863c1f6c feat(telemetry): collections, universal server-fn coverage, beacon, client tracker
- 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
2026-08-25 21:33:25 -07:00

179 lines
5.6 KiB
TypeScript

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<Tournament> => {
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<MyPrediction> => {
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<Prediction> => {
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<PredictionsLeaderboard> => {
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<Prediction | null> => {
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);
})
);