feat(admin): canonical tournament list + result entry routes

New routes /admin/tournaments and /admin/tournaments/:id. The detail
page lets admins enter tournament results once via paste-and-parse,
then calls syncTournamentResults to fan out to every linked window.

Phase 3 Task 4 of canonical tournament layer migration.
This commit is contained in:
Chris Parsons 2026-05-01 22:03:39 +00:00
parent 05c8699b0c
commit 3a4fe41eb5
No known key found for this signature in database
5 changed files with 720 additions and 1 deletions

View file

@ -1,4 +1,4 @@
import { eq, and, asc } from "drizzle-orm";
import { eq, and, asc, desc } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
@ -80,6 +80,50 @@ export async function upsertTournament(
return await createTournament(data);
}
export async function findAllTournamentsGroupedBySport(): Promise<
Array<{
sport: { id: string; name: string };
tournaments: Tournament[];
}>
> {
const db = database();
const rows = await db
.select({
tournament: schema.tournaments,
sport: {
id: schema.sports.id,
name: schema.sports.name,
},
})
.from(schema.tournaments)
.innerJoin(schema.sports, eq(schema.tournaments.sportId, schema.sports.id))
.orderBy(
asc(schema.sports.name),
desc(schema.tournaments.year),
asc(schema.tournaments.startsAt)
);
const groupMap = new Map<
string,
{ sport: { id: string; name: string }; tournaments: Tournament[] }
>();
for (const row of rows) {
const existing = groupMap.get(row.sport.id);
if (existing) {
existing.tournaments.push(row.tournament);
} else {
groupMap.set(row.sport.id, {
sport: row.sport,
tournaments: [row.tournament],
});
}
}
return Array.from(groupMap.values());
}
export async function updateTournamentStatus(
id: string,
status: TournamentStatus

View file

@ -129,6 +129,8 @@ export default [
"routes/admin.sports-seasons.$id.clone.tsx"
),
route("participants", "routes/admin.participants.tsx"),
route("tournaments", "routes/admin.tournaments._index.tsx"),
route("tournaments/:id", "routes/admin.tournaments.$id.tsx"),
route("templates", "routes/admin.templates.tsx"),
route("templates/new", "routes/admin.templates.new.tsx"),
route("templates/:id", "routes/admin.templates.$id.tsx"),

View file

@ -0,0 +1,182 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("~/lib/auth.server", () => ({
auth: { api: { getSession: vi.fn() } },
}));
vi.mock("~/models/user", () => ({
isUserAdmin: vi.fn(),
}));
vi.mock("~/models/tournament", () => ({
getTournamentById: vi.fn(),
updateTournamentStatus: vi.fn(),
}));
vi.mock("~/models/tournament-result", () => ({
getTournamentResults: vi.fn(),
upsertTournamentResult: vi.fn(),
}));
vi.mock("~/models/participant", () => ({
findCanonicalParticipantsBySport: vi.fn(),
}));
vi.mock("~/services/sync-tournament-results", () => ({
syncTournamentResults: vi.fn(),
}));
vi.mock("~/lib/logger", () => ({
logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn() },
}));
import { action } from "~/routes/admin.tournaments.$id";
import { auth } from "~/lib/auth.server";
import { isUserAdmin } from "~/models/user";
import {
getTournamentById,
updateTournamentStatus,
} from "~/models/tournament";
import { upsertTournamentResult } from "~/models/tournament-result";
import { syncTournamentResults } from "~/services/sync-tournament-results";
const TOURNAMENT_ID = "tournament-1";
const ADMIN_USER_ID = "admin-1";
const NON_ADMIN_USER_ID = "user-1";
function makeRequest(body: Record<string, string>) {
const formData = new FormData();
for (const [k, v] of Object.entries(body)) {
formData.append(k, v);
}
return new Request(`http://localhost/admin/tournaments/${TOURNAMENT_ID}`, {
method: "POST",
body: formData,
});
}
function makeActionArgs(request: Request) {
return {
request,
params: { id: TOURNAMENT_ID },
context: {} as never,
} as unknown as Parameters<typeof action>[0];
}
describe("admin.tournaments.$id action", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("batch-upsert-results: upserts each row, marks tournament completed, and fans out", async () => {
vi.mocked(auth.api.getSession).mockResolvedValue({
user: { id: ADMIN_USER_ID },
} as never);
vi.mocked(isUserAdmin).mockResolvedValue(true);
vi.mocked(getTournamentById).mockResolvedValue({
id: TOURNAMENT_ID,
sportId: "sport-1",
name: "US Open",
year: 2026,
status: "scheduled",
} as never);
vi.mocked(upsertTournamentResult).mockResolvedValue({} as never);
vi.mocked(updateTournamentStatus).mockResolvedValue({} as never);
const syncReport = {
tournamentId: TOURNAMENT_ID,
windowsSynced: 2,
windowsFailed: 0,
failures: [],
};
vi.mocked(syncTournamentResults).mockResolvedValue(syncReport);
const results = [
{ participantId: "p-1", placement: 1 },
{ participantId: "p-2", placement: 2 },
{ participantId: "p-3", placement: 3 },
];
const request = makeRequest({
intent: "batch-upsert-results",
results: JSON.stringify(results),
});
const response = await action(makeActionArgs(request));
expect(upsertTournamentResult).toHaveBeenCalledTimes(3);
expect(upsertTournamentResult).toHaveBeenCalledWith({
tournamentId: TOURNAMENT_ID,
participantId: "p-1",
placement: 1,
});
expect(updateTournamentStatus).toHaveBeenCalledWith(
TOURNAMENT_ID,
"completed"
);
expect(syncTournamentResults).toHaveBeenCalledWith(TOURNAMENT_ID);
expect(response).toMatchObject({
success: true,
syncReport,
});
});
it("does not re-update status when tournament is already completed", async () => {
vi.mocked(auth.api.getSession).mockResolvedValue({
user: { id: ADMIN_USER_ID },
} as never);
vi.mocked(isUserAdmin).mockResolvedValue(true);
vi.mocked(getTournamentById).mockResolvedValue({
id: TOURNAMENT_ID,
sportId: "sport-1",
name: "US Open",
year: 2026,
status: "completed",
} as never);
vi.mocked(upsertTournamentResult).mockResolvedValue({} as never);
vi.mocked(syncTournamentResults).mockResolvedValue({
tournamentId: TOURNAMENT_ID,
windowsSynced: 1,
windowsFailed: 0,
failures: [],
});
const request = makeRequest({
intent: "batch-upsert-results",
results: JSON.stringify([{ participantId: "p-1", placement: 1 }]),
});
await action(makeActionArgs(request));
expect(updateTournamentStatus).not.toHaveBeenCalled();
expect(syncTournamentResults).toHaveBeenCalledWith(TOURNAMENT_ID);
});
it("non-admin user is forbidden (403)", async () => {
vi.mocked(auth.api.getSession).mockResolvedValue({
user: { id: NON_ADMIN_USER_ID },
} as never);
vi.mocked(isUserAdmin).mockResolvedValue(false);
const request = makeRequest({
intent: "batch-upsert-results",
results: JSON.stringify([{ participantId: "p-1", placement: 1 }]),
});
await expect(action(makeActionArgs(request))).rejects.toMatchObject({
status: 403,
});
expect(upsertTournamentResult).not.toHaveBeenCalled();
expect(syncTournamentResults).not.toHaveBeenCalled();
});
it("missing tournament returns 404", async () => {
vi.mocked(auth.api.getSession).mockResolvedValue({
user: { id: ADMIN_USER_ID },
} as never);
vi.mocked(isUserAdmin).mockResolvedValue(true);
vi.mocked(getTournamentById).mockResolvedValue(null);
const request = makeRequest({
intent: "batch-upsert-results",
results: JSON.stringify([{ participantId: "p-1", placement: 1 }]),
});
await expect(action(makeActionArgs(request))).rejects.toMatchObject({
status: 404,
});
});
});

View file

@ -0,0 +1,353 @@
import { Link, useFetcher } from "react-router";
import { auth } from "~/lib/auth.server";
import type { Route } from "./+types/admin.tournaments.$id";
import { logger } from "~/lib/logger";
import {
getTournamentById,
updateTournamentStatus,
} from "~/models/tournament";
import {
getTournamentResults,
upsertTournamentResult,
} from "~/models/tournament-result";
import { findCanonicalParticipantsBySport } from "~/models/participant";
import { isUserAdmin } from "~/models/user";
import {
syncTournamentResults,
type SyncReport,
} from "~/services/sync-tournament-results";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import { ArrowLeft, CheckCircle2, AlertTriangle } from "lucide-react";
import { BatchResultEntry } from "~/components/BatchResultEntry";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [
{
title: `${data?.tournament?.name ?? "Tournament"} - Brackt Admin`,
},
];
}
export async function loader({ params }: Route.LoaderArgs) {
const tournament = await getTournamentById(params.id);
if (!tournament) {
throw new Response("Not Found", { status: 404 });
}
const [results, canonicalParticipants] = await Promise.all([
getTournamentResults(tournament.id),
findCanonicalParticipantsBySport(tournament.sportId),
]);
return { tournament, results, canonicalParticipants };
}
export async function action(args: Route.ActionArgs) {
const { request, params } = args;
const session = await auth.api.getSession({ headers: request.headers });
const userId = session?.user.id ?? null;
const isAdmin = userId ? await isUserAdmin(userId) : false;
if (!isAdmin) {
throw new Response("Forbidden", { status: 403 });
}
const tournament = await getTournamentById(params.id);
if (!tournament) {
throw new Response("Not Found", { status: 404 });
}
const formData = await request.formData();
const intent = formData.get("intent");
if (intent === "batch-upsert-results") {
const resultsRaw = formData.get("results");
if (typeof resultsRaw !== "string" || !resultsRaw) {
return {
success: false as const,
error: "Missing results payload",
syncReport: null,
};
}
let parsed: Array<{ participantId: string; placement: number }>;
try {
parsed = JSON.parse(resultsRaw);
} catch {
return {
success: false as const,
error: "Invalid JSON in results payload",
syncReport: null,
};
}
if (!Array.isArray(parsed)) {
return {
success: false as const,
error: "Results payload must be an array",
syncReport: null,
};
}
try {
for (const row of parsed) {
if (
!row ||
typeof row.participantId !== "string" ||
typeof row.placement !== "number"
) {
return {
success: false as const,
error: "Each result must have participantId and placement",
syncReport: null,
};
}
await upsertTournamentResult({
tournamentId: tournament.id,
participantId: row.participantId,
placement: row.placement,
});
}
if (tournament.status !== "completed") {
await updateTournamentStatus(tournament.id, "completed");
}
const syncReport = await syncTournamentResults(tournament.id);
return {
success: true as const,
error: null,
syncReport,
};
} catch (error) {
logger.error("batch-upsert-results failed:", error);
return {
success: false as const,
error:
error instanceof Error ? error.message : "Failed to save results",
syncReport: null,
};
}
}
if (intent === "retry-window-sync") {
try {
const syncReport = await syncTournamentResults(tournament.id);
return { success: true as const, error: null, syncReport };
} catch (error) {
logger.error("retry-window-sync failed:", error);
return {
success: false as const,
error:
error instanceof Error ? error.message : "Failed to retry sync",
syncReport: null,
};
}
}
return {
success: false as const,
error: "Invalid intent",
syncReport: null,
};
}
export default function AdminTournamentDetail({
loaderData,
actionData,
}: Route.ComponentProps) {
const { tournament, results, canonicalParticipants } = loaderData;
const retryFetcher = useFetcher<typeof action>();
// Prefer the latest action/retry response for the sync report
const liveReport: SyncReport | null =
(retryFetcher.data?.syncReport ?? actionData?.syncReport) ?? null;
const participantById = new Map(
canonicalParticipants.map((p) => [p.id, p])
);
return (
<div className="p-8">
<div className="max-w-5xl">
<div className="mb-6">
<Button variant="ghost" size="sm" asChild className="mb-2">
<Link to="/admin/tournaments">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to tournaments
</Link>
</Button>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold">{tournament.name}</h1>
<Badge
variant={
tournament.status === "completed"
? "default"
: tournament.status === "in_progress"
? "secondary"
: "outline"
}
>
{tournament.status}
</Badge>
</div>
<p className="text-muted-foreground mt-1">
{tournament.year}
{tournament.location ? `${tournament.location}` : ""}
{tournament.surface ? `${tournament.surface}` : ""}
</p>
</div>
{liveReport && (
<Card className="mb-6 border-emerald-500/30">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-emerald-500">
<CheckCircle2 className="h-5 w-5" />
Synced to {liveReport.windowsSynced}{" "}
{liveReport.windowsSynced === 1 ? "window" : "windows"}
</CardTitle>
<CardDescription>
Canonical results were fanned out to every linked scoring
window.
</CardDescription>
</CardHeader>
{liveReport.failures.length > 0 && (
<CardContent>
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-4 space-y-3">
<div className="flex items-center gap-2 text-destructive font-medium">
<AlertTriangle className="h-4 w-4" />
{liveReport.failures.length}{" "}
{liveReport.failures.length === 1 ? "window" : "windows"}{" "}
failed to sync
</div>
<div className="space-y-2">
{liveReport.failures.map((f) => (
<div
key={f.scoringEventId}
className="flex items-start justify-between gap-4 text-sm"
>
<div className="space-y-1">
<div className="font-mono text-xs text-muted-foreground">
event: {f.scoringEventId}
</div>
<div className="font-mono text-xs text-muted-foreground">
season: {f.sportsSeasonId}
</div>
<div className="text-destructive">{f.error}</div>
</div>
<retryFetcher.Form method="post">
<input
type="hidden"
name="intent"
value="retry-window-sync"
/>
<input
type="hidden"
name="scoringEventId"
value={f.scoringEventId}
/>
<Button
type="submit"
size="sm"
variant="outline"
disabled={retryFetcher.state !== "idle"}
>
{retryFetcher.state !== "idle"
? "Retrying…"
: "Retry"}
</Button>
</retryFetcher.Form>
</div>
))}
</div>
</div>
</CardContent>
)}
</Card>
)}
{actionData?.error && (
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm mb-6">
{actionData.error}
</div>
)}
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Current Results</CardTitle>
<CardDescription>
{results.length}{" "}
{results.length === 1 ? "result" : "results"} recorded
</CardDescription>
</CardHeader>
<CardContent>
{results.length === 0 ? (
<p className="text-sm text-muted-foreground py-6 text-center">
No results recorded yet. Paste a ranked list to the right to
import.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-24">Placement</TableHead>
<TableHead>Participant</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{results.map((r) => {
const p = participantById.get(r.participantId);
return (
<TableRow key={r.id}>
<TableCell className="font-semibold">
{r.placement ?? "—"}
</TableCell>
<TableCell>
{p?.name ?? (
<span className="text-muted-foreground font-mono text-xs">
{r.participantId}
</span>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<BatchResultEntry
participants={canonicalParticipants.map((p) => ({
id: p.id,
name: p.name,
}))}
sportsSeasonId=""
existingResultParticipantIds={
new Set(results.map((r) => r.participantId))
}
intent="batch-upsert-results"
/>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,138 @@
import { Link } from "react-router";
import type { Route } from "./+types/admin.tournaments._index";
import { findAllTournamentsGroupedBySport } from "~/models/tournament";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Trophy } from "lucide-react";
export function meta(): Route.MetaDescriptors {
return [{ title: "Tournaments - Brackt Admin" }];
}
export async function loader() {
const grouped = await findAllTournamentsGroupedBySport();
return { grouped };
}
export default function AdminTournamentsIndex({
loaderData,
}: Route.ComponentProps) {
const { grouped } = loaderData;
const totalCount = grouped.reduce(
(acc, g) => acc + g.tournaments.length,
0
);
return (
<div className="p-8">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-3xl font-bold">Tournaments</h1>
<p className="text-muted-foreground mt-1">
Canonical tournaments across all sports. Enter results once here
and they fan out to every linked scoring window.
</p>
</div>
</div>
{grouped.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<Trophy className="mx-auto h-12 w-12 text-muted-foreground" />
<h3 className="mt-4 text-lg font-semibold">
No tournaments yet
</h3>
<p className="text-muted-foreground mt-2">
Canonical tournaments are created automatically by the backfill
or data-sync flows.
</p>
</CardContent>
</Card>
) : (
<div className="space-y-6">
<p className="text-sm text-muted-foreground">
{totalCount} {totalCount === 1 ? "tournament" : "tournaments"}{" "}
across {grouped.length}{" "}
{grouped.length === 1 ? "sport" : "sports"}
</p>
{grouped.map((group) => (
<Card key={group.sport.id}>
<CardHeader>
<CardTitle>{group.sport.name}</CardTitle>
<CardDescription>
{group.tournaments.length}{" "}
{group.tournaments.length === 1
? "tournament"
: "tournaments"}
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Year</TableHead>
<TableHead>Starts</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{group.tournaments.map((t) => (
<TableRow key={t.id}>
<TableCell className="font-medium">{t.name}</TableCell>
<TableCell>{t.year}</TableCell>
<TableCell className="text-muted-foreground">
{t.startsAt
? new Date(t.startsAt).toLocaleDateString()
: "—"}
</TableCell>
<TableCell>
<Badge
variant={
t.status === "completed"
? "default"
: t.status === "in_progress"
? "secondary"
: "outline"
}
>
{t.status}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" asChild>
<Link to={`/admin/tournaments/${t.id}`}>
Open
</Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}