diff --git a/app/components/BatchResultEntry.tsx b/app/components/BatchResultEntry.tsx index 9152639..6f2ccd3 100644 --- a/app/components/BatchResultEntry.tsx +++ b/app/components/BatchResultEntry.tsx @@ -46,12 +46,18 @@ interface BatchResultEntryProps { participants: Participant[]; sportsSeasonId: string; existingResultParticipantIds: Set; + /** + * Form intent name posted on "Save All". Defaults to "batch-add-results" for + * the existing per-window flow. Canonical tournament pages override this. + */ + intent?: string; } export function BatchResultEntry({ participants, sportsSeasonId, existingResultParticipantIds, + intent = "batch-add-results", }: BatchResultEntryProps) { const [stage, setStage] = useState("idle"); const [pasteText, setPasteText] = useState(""); @@ -172,7 +178,7 @@ export function BatchResultEntry({ placement: r.placement, })); const formData = new FormData(); - formData.set("intent", "batch-add-results"); + formData.set("intent", intent); formData.set("results", JSON.stringify(results)); saveFetcher.submit(formData, { method: "post" }); } diff --git a/app/lib/tournament-identity.ts b/app/lib/tournament-identity.ts new file mode 100644 index 0000000..1cce590 --- /dev/null +++ b/app/lib/tournament-identity.ts @@ -0,0 +1,50 @@ +/** + * Extracts the canonical tournament identity — `(name, year)` — from a scoring + * event. Used both by the Phase 2 backfill and by the admin create-event flow + * that auto-provisions a canonical tournament for new qualifying events. + * + * Rules: + * - If the event name ends with a 4-digit year, strip it; that year wins. + * - Otherwise, fall back to the year portion of eventDate (YYYY-MM-DD). + * - If neither produces a year, throw. + */ + +export interface ScoringEventIdentityInput { + name: string; + eventDate: string | Date | null | undefined; + eventType: string; +} + +export interface TournamentIdentity { + name: string; + year: number; +} + +const YEAR_SUFFIX = /\s+(\d{4})\s*$/; + +export function extractTournamentIdentity(ev: ScoringEventIdentityInput): TournamentIdentity { + const trimmed = ev.name.trim(); + const yearMatch = trimmed.match(YEAR_SUFFIX); + let cleanName = trimmed; + let year: number | null = null; + + if (yearMatch) { + year = parseInt(yearMatch[1], 10); + cleanName = trimmed.replace(YEAR_SUFFIX, "").trim(); + } + + if (year === null && ev.eventDate !== null && ev.eventDate !== undefined) { + const iso = ev.eventDate instanceof Date + ? ev.eventDate.toISOString() + : String(ev.eventDate); + year = parseInt(iso.slice(0, 4), 10); + } + + if (year === null || Number.isNaN(year)) { + throw new Error( + `cannot determine year for event "${ev.name}" — no year in name and eventDate is null`, + ); + } + + return { name: cleanName, year }; +} diff --git a/app/models/__tests__/surface-elo.test.ts b/app/models/__tests__/surface-elo.test.ts index 307a87a..2db14a9 100644 --- a/app/models/__tests__/surface-elo.test.ts +++ b/app/models/__tests__/surface-elo.test.ts @@ -42,10 +42,10 @@ describe("getSurfaceEloMap", () => { expect(result.size).toBe(0); }); - it("returns a Map keyed by participantId", async () => { + it("returns a Map keyed by seasonParticipantId", async () => { mockDb.where.mockResolvedValue([ - { participantId: "p1", worldRanking: 1, eloHard: 1800, eloClay: 1700, eloGrass: 1600 }, - { participantId: "p2", worldRanking: null, eloHard: null, eloClay: 1750, eloGrass: null }, + { seasonParticipantId: "p1", worldRanking: 1, eloHard: 1800, eloClay: 1700, eloGrass: 1600 }, + { seasonParticipantId: "p2", worldRanking: null, eloHard: null, eloClay: 1750, eloGrass: null }, ]); const result = await getSurfaceEloMap("season-1"); expect(result.size).toBe(2); @@ -55,7 +55,7 @@ describe("getSurfaceEloMap", () => { it("preserves null surface Elos (does not coerce to 0)", async () => { mockDb.where.mockResolvedValue([ - { participantId: "p1", worldRanking: null, eloHard: null, eloClay: null, eloGrass: null }, + { seasonParticipantId: "p1", worldRanking: null, eloHard: null, eloClay: null, eloGrass: null }, ]); const result = await getSurfaceEloMap("season-1"); const entry = result.get("p1"); @@ -72,14 +72,21 @@ describe("batchUpsertSurfaceElos", () => { expect(mockDb.insert).not.toHaveBeenCalled(); }); - it("calls insert with the correct values", async () => { - const inputs = [ + it("writes canonical rows using resolved canonical participant ids", async () => { + // season_participants lookup returns canonical links for p1 and p2. + mockDb.where.mockResolvedValue([ + { id: "p1", canonicalId: "canon-1" }, + { id: "p2", canonicalId: "canon-2" }, + ]); + + await batchUpsertSurfaceElos([ { participantId: "p1", sportsSeasonId: "s1", worldRanking: 1, eloHard: 1800, eloClay: 1700, eloGrass: 1600 }, { participantId: "p2", sportsSeasonId: "s1", worldRanking: null, eloHard: null, eloClay: 1750, eloGrass: undefined }, - ]; - await batchUpsertSurfaceElos(inputs); + ]); + expect(mockDb.insert).toHaveBeenCalledOnce(); expect(mockDb.values).toHaveBeenCalledOnce(); + expect(mockDb.onConflictDoUpdate).toHaveBeenCalledOnce(); const passedValues = mockDb.values.mock.calls[0][0] as Array<{ participantId: string; @@ -88,17 +95,21 @@ describe("batchUpsertSurfaceElos", () => { eloGrass: number | null; }>; expect(passedValues).toHaveLength(2); - expect(passedValues[0].participantId).toBe("p1"); + expect(passedValues[0].participantId).toBe("canon-1"); expect(passedValues[0].eloHard).toBe(1800); + expect(passedValues[1].participantId).toBe("canon-2"); expect(passedValues[1].eloHard).toBeNull(); expect(passedValues[1].eloGrass).toBeNull(); // undefined → null }); - it("uses onConflictDoUpdate for upsert behaviour", async () => { + it("skips season_participants that have no canonical link", async () => { + mockDb.where.mockResolvedValue([]); // no canonical links; short-circuits. + await batchUpsertSurfaceElos([ { participantId: "p1", sportsSeasonId: "s1", worldRanking: 5, eloHard: 2000 }, ]); - expect(mockDb.onConflictDoUpdate).toHaveBeenCalledOnce(); + + expect(mockDb.insert).not.toHaveBeenCalled(); }); }); diff --git a/app/models/scoring-event.ts b/app/models/scoring-event.ts index e5dfb15..f0ea0b2 100644 --- a/app/models/scoring-event.ts +++ b/app/models/scoring-event.ts @@ -24,6 +24,8 @@ export interface CreateScoringEventData { eventStartsAt?: Date; eventType: EventType; isQualifyingEvent?: boolean; + /** Required when isQualifyingEvent=true (DB check constraint). */ + tournamentId?: string; // Template system fields (Phase 2.6) bracketTemplateId?: string; scoringStartsAtRound?: string; @@ -59,6 +61,7 @@ export async function createScoringEvent( eventStartsAt: data.eventStartsAt, eventType: data.eventType, isQualifyingEvent: data.isQualifyingEvent || false, + tournamentId: data.tournamentId, bracketTemplateId: data.bracketTemplateId, scoringStartsAtRound: data.scoringStartsAtRound, isComplete: false, @@ -743,7 +746,7 @@ export async function getEventsForDates( */ export async function bulkCreateScoringEvents( sportsSeasonId: string, - events: Array<{ name: string; eventDate?: Date; eventStartsAt?: Date; eventType: EventType; isQualifyingEvent?: boolean }>, + events: Array<{ name: string; eventDate?: Date; eventStartsAt?: Date; eventType: EventType; isQualifyingEvent?: boolean; tournamentId?: string }>, providedDb?: ReturnType ) { const db = providedDb || database(); @@ -757,6 +760,7 @@ export async function bulkCreateScoringEvents( eventStartsAt: e.eventStartsAt, eventType: e.eventType, isQualifyingEvent: e.isQualifyingEvent ?? false, + tournamentId: e.tournamentId, isComplete: false, })); diff --git a/app/models/season-participant.ts b/app/models/season-participant.ts index 5d4850c..7655700 100644 --- a/app/models/season-participant.ts +++ b/app/models/season-participant.ts @@ -7,18 +7,86 @@ export type NewParticipant = typeof schema.seasonParticipants.$inferInsert; export async function createParticipant(data: NewParticipant): Promise { const db = database(); + + // Auto-link to canonical participant (by sportId + name) if caller didn't + // supply one. Needed so syncTournamentResults can match this roster entry + // to canonical tournament_results; without the link the participant is + // invisible to the canonical layer. + let canonicalId = data.participantId; + if (!canonicalId) { + const season = await db.query.sportsSeasons.findFirst({ + where: eq(schema.sportsSeasons.id, data.sportsSeasonId), + columns: { sportId: true }, + }); + if (season) { + const existing = await db.query.participants.findFirst({ + where: and( + eq(schema.participants.sportId, season.sportId), + eq(schema.participants.name, data.name), + ), + }); + if (existing) { + canonicalId = existing.id; + } else { + const [created] = await db + .insert(schema.participants) + .values({ sportId: season.sportId, name: data.name }) + .returning(); + canonicalId = created.id; + } + } + } + const [participant] = await db .insert(schema.seasonParticipants) - .values(data) + .values({ ...data, participantId: canonicalId }) .returning(); return participant; } export async function createManyParticipants(data: NewParticipant[]): Promise { const db = database(); + if (data.length === 0) return []; + + // Build canonical links per row, just like createParticipant does. + const seasonIds = Array.from(new Set(data.map((d) => d.sportsSeasonId))); + const seasons = await db.query.sportsSeasons.findMany({ + where: inArray(schema.sportsSeasons.id, seasonIds), + columns: { id: true, sportId: true }, + }); + const sportBySeason = new Map(seasons.map((s) => [s.id, s.sportId])); + + const enriched: NewParticipant[] = []; + for (const row of data) { + if (row.participantId) { + enriched.push(row); + continue; + } + const sportId = sportBySeason.get(row.sportsSeasonId); + if (!sportId) { + enriched.push(row); + continue; + } + const existing = await db.query.participants.findFirst({ + where: and( + eq(schema.participants.sportId, sportId), + eq(schema.participants.name, row.name), + ), + }); + let canonicalId = existing?.id; + if (!canonicalId) { + const [created] = await db + .insert(schema.participants) + .values({ sportId, name: row.name }) + .returning(); + canonicalId = created.id; + } + enriched.push({ ...row, participantId: canonicalId }); + } + return await db .insert(schema.seasonParticipants) - .values(data) + .values(enriched) .returning(); } diff --git a/app/models/surface-elo.ts b/app/models/surface-elo.ts index edb4a27..b7d903c 100644 --- a/app/models/surface-elo.ts +++ b/app/models/surface-elo.ts @@ -1,14 +1,18 @@ /** - * Model for Participant Surface Elos + * Surface Elo model (canonical). * - * Manages surface-specific Elo ratings for tennis (and future surface-based sports). - * Each participant in a sports season can have separate Elo ratings for hard, - * clay, and grass courts. + * Surface Elos are stored in the canonical `participant_surface_elos` table, + * keyed by canonical participant id. The admin UI still works in terms of + * season_participants — we join through season_participants → canonical + * participant → canonical surface Elo so the UI doesn't need to know about + * the canonical layer. + * + * See `docs/superpowers/specs/2026-05-01-canonical-tournament-layer-design.md`. */ import { database } from "~/database/context"; -import { seasonParticipantSurfaceElos, seasonParticipants } from "~/database/schema"; -import { eq, sql } from "drizzle-orm"; +import { participantSurfaceElos, seasonParticipants } from "~/database/schema"; +import { eq, inArray, sql } from "drizzle-orm"; export type CourtSurface = "hard" | "clay" | "grass"; @@ -37,60 +41,82 @@ export interface SurfaceEloInput { } /** - * Get all surface Elo records for a sports season, joined with participant names. - * Returns one record per participant (seasonParticipants with no Elo record are excluded). + * Load surface Elos for a sports season's roster, joined with the per-window + * participant's name. Internally joins season_participants → canonical + * participants → canonical participant_surface_elos. + * + * The returned `id` is the canonical `participant_surface_elos.id`; + * `participantId` is the season_participant id (admin UI keys rows by that). */ export async function getSurfaceElosForSeason( - sportsSeasonId: string + sportsSeasonId: string, ): Promise { const db = database(); - const rows = await db + return await db .select({ - id: seasonParticipantSurfaceElos.id, - participantId: seasonParticipantSurfaceElos.participantId, - sportsSeasonId: seasonParticipantSurfaceElos.sportsSeasonId, - worldRanking: seasonParticipantSurfaceElos.worldRanking, - eloHard: seasonParticipantSurfaceElos.eloHard, - eloClay: seasonParticipantSurfaceElos.eloClay, - eloGrass: seasonParticipantSurfaceElos.eloGrass, - updatedAt: seasonParticipantSurfaceElos.updatedAt, + id: participantSurfaceElos.id, + participantId: seasonParticipants.id, + sportsSeasonId: seasonParticipants.sportsSeasonId, + worldRanking: participantSurfaceElos.worldRanking, + eloHard: participantSurfaceElos.eloHard, + eloClay: participantSurfaceElos.eloClay, + eloGrass: participantSurfaceElos.eloGrass, + updatedAt: participantSurfaceElos.updatedAt, participantName: seasonParticipants.name, }) - .from(seasonParticipantSurfaceElos) - .innerJoin(seasonParticipants, eq(seasonParticipantSurfaceElos.participantId, seasonParticipants.id)) - .where(eq(seasonParticipantSurfaceElos.sportsSeasonId, sportsSeasonId)) + .from(seasonParticipants) + .innerJoin( + participantSurfaceElos, + eq(participantSurfaceElos.participantId, seasonParticipants.participantId), + ) + .where(eq(seasonParticipants.sportsSeasonId, sportsSeasonId)) .orderBy(seasonParticipants.name); - - return rows; } /** - * Upsert surface Elo ratings for a batch of seasonParticipants. - * Uses INSERT ... ON CONFLICT DO UPDATE so all three surface columns are - * overwritten atomically — the admin always submits all three values. + * Upsert surface Elos for a batch of season_participants. Writes to the + * canonical `participant_surface_elos` table; callers pass season_participant + * ids and we resolve canonical ids internally. + * + * season_participants without a canonical link are silently skipped. In + * practice every qualifying-points roster entry is canonical-linked after + * Phase 2 + the Phase 3 auto-linking on createParticipant. */ export async function batchUpsertSurfaceElos( - inputs: SurfaceEloInput[] + inputs: SurfaceEloInput[], ): Promise { if (inputs.length === 0) return; const db = database(); const now = new Date(); + // Resolve canonical ids for every season_participant in the batch. + const sps = await db + .select({ id: seasonParticipants.id, canonicalId: seasonParticipants.participantId }) + .from(seasonParticipants) + .where(inArray(seasonParticipants.id, inputs.map((i) => i.participantId))); + const canonicalByInputId = new Map(sps.map((sp) => [sp.id, sp.canonicalId])); + + const canonicalRows = inputs + .map((i) => ({ + canonicalId: canonicalByInputId.get(i.participantId), + worldRanking: i.worldRanking ?? null, + eloHard: i.eloHard ?? null, + eloClay: i.eloClay ?? null, + eloGrass: i.eloGrass ?? null, + })) + .filter((r): r is typeof r & { canonicalId: string } => typeof r.canonicalId === "string"); + + if (canonicalRows.length === 0) return; + await db - .insert(seasonParticipantSurfaceElos) - .values( - inputs.map(({ participantId, sportsSeasonId, worldRanking, eloHard, eloClay, eloGrass }) => ({ - participantId, - sportsSeasonId, - worldRanking: worldRanking ?? null, - eloHard: eloHard ?? null, - eloClay: eloClay ?? null, - eloGrass: eloGrass ?? null, - updatedAt: now, - })) - ) + .insert(participantSurfaceElos) + .values(canonicalRows.map(({ canonicalId, ...rest }) => ({ + participantId: canonicalId, + ...rest, + updatedAt: now, + }))) .onConflictDoUpdate({ - target: [seasonParticipantSurfaceElos.participantId, seasonParticipantSurfaceElos.sportsSeasonId], + target: [participantSurfaceElos.participantId], set: { worldRanking: sql`excluded.world_ranking`, eloHard: sql`excluded.elo_hard`, @@ -102,25 +128,34 @@ export async function batchUpsertSurfaceElos( } /** - * Returns a Map from participantId to surface Elos for use in the simulator. - * Participants with no record are absent from the map (simulator falls back to 1500). + * Build a map keyed by season_participant.id → surface Elo values, sourced + * from the canonical `participant_surface_elos` table. Used by the tennis + * simulator. + * + * Season participants without a linked canonical participant, or with no + * canonical surface Elo row, are absent from the map — callers handle + * `undefined` by falling back to 1500 (the simulator's default). */ export async function getSurfaceEloMap( - sportsSeasonId: string + sportsSeasonId: string, ): Promise> { const db = database(); const rows = await db .select({ - participantId: seasonParticipantSurfaceElos.participantId, - worldRanking: seasonParticipantSurfaceElos.worldRanking, - eloHard: seasonParticipantSurfaceElos.eloHard, - eloClay: seasonParticipantSurfaceElos.eloClay, - eloGrass: seasonParticipantSurfaceElos.eloGrass, + seasonParticipantId: seasonParticipants.id, + worldRanking: participantSurfaceElos.worldRanking, + eloHard: participantSurfaceElos.eloHard, + eloClay: participantSurfaceElos.eloClay, + eloGrass: participantSurfaceElos.eloGrass, }) - .from(seasonParticipantSurfaceElos) - .where(eq(seasonParticipantSurfaceElos.sportsSeasonId, sportsSeasonId)); + .from(seasonParticipants) + .innerJoin( + participantSurfaceElos, + eq(participantSurfaceElos.participantId, seasonParticipants.participantId), + ) + .where(eq(seasonParticipants.sportsSeasonId, sportsSeasonId)); - return new Map(rows.map((r) => [r.participantId, { + return new Map(rows.map((r) => [r.seasonParticipantId, { worldRanking: r.worldRanking, eloHard: r.eloHard, eloClay: r.eloClay, 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.sports-seasons.$id.events.$eventId.server.ts b/app/routes/admin.sports-seasons.$id.events.$eventId.server.ts index c1a997b..7e9f0e2 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.server.ts +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.server.ts @@ -16,7 +16,7 @@ import { type CreateEventResultData, type UpdateEventResultData, } from "~/models/event-result"; -import { filterNewResults, findParticipantsWithoutResults } from "./admin.sports-seasons.$id.events.$eventId.helpers"; +import { findParticipantsWithoutResults } from "./admin.sports-seasons.$id.events.$eventId.helpers"; import { findParticipantResultsBySportsSeasonId } from "~/models/participant-result"; import { upsertParticipantSeasonResult, @@ -27,6 +27,8 @@ import { getQPStandings, getQPConfig } from "~/models/qualifying-points"; import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq } from "drizzle-orm"; +import { upsertTournamentResult } from "~/models/tournament-result"; +import { syncTournamentResults } from "~/services/sync-tournament-results"; export async function loader({ params }: Route.LoaderArgs) { const sportsSeason = await findSportsSeasonById(params.id); @@ -372,22 +374,49 @@ export async function action({ request, params }: Route.ActionArgs) { } try { - const existingResults = await getEventResults(params.eventId); - const existingIds = new Set(existingResults.map((r) => r.seasonParticipantId)); - const newResults = filterNewResults(incoming, existingIds); - - if (newResults.length > 0) { - await createEventResultsBulk( - newResults.map((r) => ({ - scoringEventId: params.eventId, - participantId: r.participantId, - placement: r.placement, - })) - ); + // If this scoring event is linked to a canonical tournament, write + // results to the canonical layer and fan out via syncTournamentResults. + // This keeps sibling windows (if any) in sync automatically. + const scoringEvent = await database().query.scoringEvents.findFirst({ + where: eq(schema.scoringEvents.id, params.eventId), + }); + if (!scoringEvent) { + return { error: "Scoring event not found" }; } + // Qualifying events always have a canonical tournament link (check + // constraint). Write results canonically and fan out. + if (!scoringEvent.tournamentId) { + return { + error: + "This scoring event has no canonical tournament link — cannot save batch results. Contact a developer.", + }; + } + + const spRows = allSeasonParticipants.filter((sp) => + incoming.some((r) => r.participantId === sp.id), + ); + const missingCanonical = spRows.filter((sp) => !sp.participantId); + if (missingCanonical.length > 0) { + return { + error: `Some season participants are not linked to canonical participants: ${missingCanonical.map((sp) => sp.name).join(", ")}`, + }; + } + + for (const row of incoming) { + const sp = spRows.find((s) => s.id === row.participantId); + if (!sp?.participantId) continue; + await upsertTournamentResult({ + tournamentId: scoringEvent.tournamentId, + participantId: sp.participantId, + placement: row.placement, + }); + } + + const syncReport = await syncTournamentResults(scoringEvent.tournamentId); return { - success: `${newResults.length} result${newResults.length === 1 ? "" : "s"} saved${incoming.length - newResults.length > 0 ? ` (${incoming.length - newResults.length} duplicate${incoming.length - newResults.length === 1 ? "" : "s"} skipped)` : ""}.`, + success: `${incoming.length} result${incoming.length === 1 ? "" : "s"} saved canonically. Synced to ${syncReport.windowsSynced} window${syncReport.windowsSynced === 1 ? "" : "s"}${syncReport.windowsFailed > 0 ? ` (${syncReport.windowsFailed} failed)` : ""}.`, + syncReport, }; } catch (error) { logger.error("Error saving batch results:", error); diff --git a/app/routes/admin.sports-seasons.$id.events.server.ts b/app/routes/admin.sports-seasons.$id.events.server.ts index ff103ac..30c0094 100644 --- a/app/routes/admin.sports-seasons.$id.events.server.ts +++ b/app/routes/admin.sports-seasons.$id.events.server.ts @@ -11,6 +11,8 @@ import { } from "~/models/scoring-event"; import { getQPStandings } from "~/models/qualifying-points"; import { finalizeQualifyingPoints } from "~/models/scoring-calculator"; +import { upsertTournament } from "~/models/tournament"; +import { extractTournamentIdentity } from "~/lib/tournament-identity"; export async function loader({ params }: Route.LoaderArgs) { @@ -107,6 +109,31 @@ export async function action({ request, params }: Route.ActionArgs) { return { error: "No valid events found. Use format: Event Name, YYYY-MM-DD" }; } + // Auto-provision canonical tournaments for qualifying events (check constraint). + try { + for (const ev of validEvents) { + if (!ev.isQualifyingEvent) continue; + const identity = extractTournamentIdentity({ + name: ev.name, + eventDate: ev.eventDate ? ev.eventDate.toISOString().slice(0, 10) : null, + eventType: ev.eventType, + }); + const tournament = await upsertTournament({ + sportId: sportsSeason.sportId, + name: identity.name, + year: identity.year, + startsAt: ev.eventStartsAt ?? null, + }); + (ev as typeof ev & { tournamentId: string }).tournamentId = tournament.id; + } + } catch (err) { + logger.error("Error linking qualifying events to canonical tournaments:", err); + return { + error: + "Could not determine tournament identity for one or more qualifying events. Include a 4-digit year or date for each.", + }; + } + try { await bulkCreateScoringEvents(params.id, validEvents); return { success: `Created ${validEvents.length} event${validEvents.length !== 1 ? "s" : ""} successfully` }; @@ -178,6 +205,31 @@ export async function action({ request, params }: Route.ActionArgs) { isQualifyingEvent, }; + // Qualifying events require a canonical tournament_id (check constraint). + // Auto-provision one by extracting (name, year) from the event name/date. + if (isQualifyingEvent) { + try { + const identity = extractTournamentIdentity({ + name: eventData.name, + eventDate: eventDate ? eventDate.toISOString().slice(0, 10) : null, + eventType, + }); + const tournament = await upsertTournament({ + sportId: sportsSeason.sportId, + name: identity.name, + year: identity.year, + startsAt: eventStartsAt ?? null, + }); + eventData.tournamentId = tournament.id; + } catch (err) { + logger.error("Error linking qualifying event to canonical tournament:", err); + return { + error: + "Could not determine tournament identity. Include a 4-digit year in the event name, or set a date.", + }; + } + } + try { const event = await createScoringEvent(eventData); return redirect(`/admin/sports-seasons/${params.id}/events/${event.id}`); 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} + + + + + + + ))} + +
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/app/routes/admin.tsx b/app/routes/admin.tsx index e56e172..53bd4f6 100644 --- a/app/routes/admin.tsx +++ b/app/routes/admin.tsx @@ -10,6 +10,7 @@ import { Calendar, FolderKanban, RefreshCw, + Award, } from "lucide-react"; export function meta(): Route.MetaDescriptors { @@ -59,6 +60,12 @@ export default function AdminLayout() { Sports Seasons +