diff --git a/app/models/tournament.ts b/app/models/tournament.ts index e26659c..2bf7c51 100644 --- a/app/models/tournament.ts +++ b/app/models/tournament.ts @@ -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 diff --git a/app/routes.ts b/app/routes.ts index 9b784d6..9ba6e9b 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -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"), diff --git a/app/routes/__tests__/admin.tournaments.$id.test.tsx b/app/routes/__tests__/admin.tournaments.$id.test.tsx new file mode 100644 index 0000000..1c9059f --- /dev/null +++ b/app/routes/__tests__/admin.tournaments.$id.test.tsx @@ -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) { + 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[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, + }); + }); +}); diff --git a/app/routes/admin.tournaments.$id.tsx b/app/routes/admin.tournaments.$id.tsx new file mode 100644 index 0000000..d91e49b --- /dev/null +++ b/app/routes/admin.tournaments.$id.tsx @@ -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(); + + // 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 ( +
+
+
+ +
+

{tournament.name}

+ + {tournament.status} + +
+

+ {tournament.year} + {tournament.location ? ` — ${tournament.location}` : ""} + {tournament.surface ? ` — ${tournament.surface}` : ""} +

+
+ + {liveReport && ( + + + + + Synced to {liveReport.windowsSynced}{" "} + {liveReport.windowsSynced === 1 ? "window" : "windows"} + + + Canonical results were fanned out to every linked scoring + window. + + + {liveReport.failures.length > 0 && ( + +
+
+ + {liveReport.failures.length}{" "} + {liveReport.failures.length === 1 ? "window" : "windows"}{" "} + failed to sync +
+
+ {liveReport.failures.map((f) => ( +
+
+
+ event: {f.scoringEventId} +
+
+ season: {f.sportsSeasonId} +
+
{f.error}
+
+ + + + + +
+ ))} +
+
+
+ )} +
+ )} + + {actionData?.error && ( +
+ {actionData.error} +
+ )} + +
+ + + Current Results + + {results.length}{" "} + {results.length === 1 ? "result" : "results"} recorded + + + + {results.length === 0 ? ( +

+ No results recorded yet. Paste a ranked list to the right to + import. +

+ ) : ( + + + + Placement + Participant + + + + {results.map((r) => { + const p = participantById.get(r.participantId); + return ( + + + {r.placement ?? "—"} + + + {p?.name ?? ( + + {r.participantId} + + )} + + + ); + })} + +
+ )} +
+
+ + ({ + id: p.id, + name: p.name, + }))} + sportsSeasonId="" + existingResultParticipantIds={ + new Set(results.map((r) => r.participantId)) + } + intent="batch-upsert-results" + /> +
+
+
+ ); +} diff --git a/app/routes/admin.tournaments._index.tsx b/app/routes/admin.tournaments._index.tsx new file mode 100644 index 0000000..5753b05 --- /dev/null +++ b/app/routes/admin.tournaments._index.tsx @@ -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 ( +
+
+
+

Tournaments

+

+ Canonical tournaments across all sports. Enter results once here + and they fan out to every linked scoring window. +

+
+
+ + {grouped.length === 0 ? ( + + + +

+ No tournaments yet +

+

+ Canonical tournaments are created automatically by the backfill + or data-sync flows. +

+
+
+ ) : ( +
+

+ {totalCount} {totalCount === 1 ? "tournament" : "tournaments"}{" "} + across {grouped.length}{" "} + {grouped.length === 1 ? "sport" : "sports"} +

+ {grouped.map((group) => ( + + + {group.sport.name} + + {group.tournaments.length}{" "} + {group.tournaments.length === 1 + ? "tournament" + : "tournaments"} + + + + + + + Name + Year + Starts + Status + Actions + + + + {group.tournaments.map((t) => ( + + {t.name} + {t.year} + + {t.startsAt + ? new Date(t.startsAt).toLocaleDateString() + : "—"} + + + + {t.status} + + + + + + + ))} + +
+
+
+ ))} +
+ )} +
+ ); +}