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 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-05-01 22:32:16 +00:00
parent 7483cd628b
commit f3937d0d84
No known key found for this signature in database
5 changed files with 185 additions and 57 deletions

View file

@ -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 };
}

View file

@ -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<typeof database>
) {
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,
}));

View file

@ -7,18 +7,86 @@ export type NewParticipant = typeof schema.seasonParticipants.$inferInsert;
export async function createParticipant(data: NewParticipant): Promise<Participant> {
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<Participant[]> {
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();
}

View file

@ -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}`);

View file

@ -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";