- 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
179 lines
5.6 KiB
TypeScript
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);
|
|
})
|
|
);
|