From f3937d0d84497534bdd4c42ce63d9fffa06539f2 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Fri, 1 May 2026 22:32:16 +0000 Subject: [PATCH] feat: auto-provision canonical tournaments and participants on create Creating new qualifying scoring_events or season_participants now auto-upserts the matching canonical rows. Needed so the check constraint doesn't reject qualifying events, and so new season participants flow through syncTournamentResults. - Extract tournament identity shared between backfill and event-creation into app/lib/tournament-identity.ts; scripts/backfill re-exports it. - createScoringEvent + bulkCreateScoringEvents now accept tournamentId. - Event creation routes auto-compute (name, year) and upsert tournament. - createParticipant + createManyParticipants auto-link to canonical participants by (sportId, name). Phase 3 Task 7 of canonical tournament layer migration. Co-Authored-By: Claude Opus 4.7 --- app/lib/tournament-identity.ts | 50 +++++++++++++ app/models/scoring-event.ts | 6 +- app/models/season-participant.ts | 72 ++++++++++++++++++- .../admin.sports-seasons.$id.events.server.ts | 52 ++++++++++++++ scripts/backfill/match-tournament.ts | 62 +++------------- 5 files changed, 185 insertions(+), 57 deletions(-) create mode 100644 app/lib/tournament-identity.ts diff --git a/app/lib/tournament-identity.ts b/app/lib/tournament-identity.ts new file mode 100644 index 0000000..0292908 --- /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) { + 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/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/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/scripts/backfill/match-tournament.ts b/scripts/backfill/match-tournament.ts index e7c7c92..d869e6f 100644 --- a/scripts/backfill/match-tournament.ts +++ b/scripts/backfill/match-tournament.ts @@ -1,57 +1,11 @@ /** - * Pure function(s) for extracting canonical tournament identity - * (name + year) from a `scoring_events` row. - * - * Used by the Phase 2 backfill to group per-window events into - * canonical `tournaments` rows. + * Re-exports the canonical tournament identity extractor. The implementation + * lives in `app/lib/tournament-identity.ts` so it can be shared by the + * Phase 2 backfill and by the admin event-creation flow. */ -export interface ScoringEventInput { - name: string; - eventDate: string | null; - eventType: string; -} - -export interface TournamentIdentity { - /** Tournament name with any trailing 4-digit year stripped. */ - name: string; - year: number; -} - -const TRAILING_YEAR_RE = / (\d{4})$/; - -/** - * Extracts the canonical `(name, year)` identity for a tournament - * from a scoring event. - * - * Resolution order for year: - * 1. A trailing 4-digit year on the event name (e.g. "Wimbledon 2026"). - * The year is stripped from the returned name. - * 2. The first 4 characters of `eventDate` (format `YYYY-MM-DD`). - * - * Throws if neither source supplies a year. - */ -export function extractTournamentIdentity( - ev: ScoringEventInput, -): TournamentIdentity { - const trimmedName = ev.name.trim(); - const match = trimmedName.match(TRAILING_YEAR_RE); - - if (match) { - const yearFromName = Number(match[1]); - const nameWithoutYear = trimmedName.slice(0, match.index).trim(); - return { name: nameWithoutYear, year: yearFromName }; - } - - if (ev.eventDate) { - const yearStr = ev.eventDate.slice(0, 4); - const yearFromDate = Number(yearStr); - if (Number.isFinite(yearFromDate) && yearStr.length === 4) { - return { name: trimmedName, year: yearFromDate }; - } - } - - throw new Error( - `cannot determine year for scoring event "${ev.name}" (eventDate=${ev.eventDate ?? "null"})`, - ); -} +export { + extractTournamentIdentity, + type TournamentIdentity, + type ScoringEventIdentityInput as ScoringEventInput, +} from "~/lib/tournament-identity";