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:
parent
7483cd628b
commit
f3937d0d84
5 changed files with 185 additions and 57 deletions
50
app/lib/tournament-identity.ts
Normal file
50
app/lib/tournament-identity.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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,
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue