brackt/app/models/season-participant.ts
Chris Parsons f3937d0d84
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>
2026-05-01 22:32:16 +00:00

268 lines
8.1 KiB
TypeScript

import { eq, inArray, count, and, sql, asc, desc } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
export type Participant = typeof schema.seasonParticipants.$inferSelect;
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, 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(enriched)
.returning();
}
export async function findParticipantById(id: string): Promise<Participant | undefined> {
const db = database();
return await db.query.seasonParticipants.findFirst({
where: eq(schema.seasonParticipants.id, id),
with: {
sportsSeason: {
with: {
sport: true,
},
},
},
});
}
export async function findParticipantByName(
sportsSeasonId: string,
name: string
): Promise<Participant | undefined> {
const db = database();
const results = await db
.select()
.from(schema.seasonParticipants)
.where(
and(
eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId),
sql`lower(${schema.seasonParticipants.name}) = lower(${name})`
)
)
.limit(1);
return results[0];
}
export async function findParticipantsBySportsSeasonId(sportsSeasonId: string): Promise<Participant[]> {
const db = database();
return await db.query.seasonParticipants.findMany({
where: eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId),
orderBy: (participants, { asc: orderAsc }) => [orderAsc(participants.name)],
});
}
export async function findParticipantsByExternalId(
externalId: string
): Promise<Participant[]> {
const db = database();
return await db.query.seasonParticipants.findMany({
where: eq(schema.seasonParticipants.externalId, externalId),
with: {
sportsSeason: {
with: {
sport: true,
},
},
},
});
}
export async function updateParticipant(
id: string,
data: Partial<NewParticipant>
): Promise<Participant> {
const db = database();
const [participant] = await db
.update(schema.seasonParticipants)
.set({ ...data, updatedAt: new Date() })
.where(eq(schema.seasonParticipants.id, id))
.returning();
return participant;
}
export async function countAllParticipants(): Promise<number> {
const db = database();
const result = await db.select({ value: count() }).from(schema.seasonParticipants);
return result[0].value;
}
export async function deleteParticipant(id: string): Promise<void> {
const db = database();
await db.delete(schema.seasonParticipants).where(eq(schema.seasonParticipants.id, id));
}
export async function copyParticipantsFromSeason(
sourceSportsSeasonId: string,
targetSportsSeasonId: string
): Promise<Participant[]> {
// Get all participants from source season
const sourceParticipants = await findParticipantsBySportsSeasonId(sourceSportsSeasonId);
// Insert them into the target season
if (sourceParticipants.length === 0) {
return [];
}
return await createManyParticipants(
sourceParticipants.map((p) => ({
sportsSeasonId: targetSportsSeasonId,
name: p.name,
shortName: p.shortName,
externalId: p.externalId,
expectedValue: p.expectedValue,
}))
);
}
/**
* Get all participants for a season with sport information
* Used for draft eligibility calculations
*/
export async function getParticipantsForSeasonWithSports(seasonId: string, providedDb?: ReturnType<typeof database>) {
const db = providedDb || database();
// First get all sports seasons for this season
const seasonSports = await db.query.seasonSports.findMany({
where: eq(schema.seasonSports.seasonId, seasonId),
});
if (seasonSports.length === 0) {
return [];
}
const sportsSeasonIds = seasonSports.map((ss) => ss.sportsSeasonId);
// Get all participants for these sports seasons with sport info
const participants = await db
.select({
id: schema.seasonParticipants.id,
name: schema.seasonParticipants.name,
sport: {
id: schema.sports.id,
name: schema.sports.name,
},
})
.from(schema.seasonParticipants)
.innerJoin(
schema.sportsSeasons,
eq(schema.seasonParticipants.sportsSeasonId, schema.sportsSeasons.id)
)
.innerJoin(
schema.sports,
eq(schema.sportsSeasons.sportId, schema.sports.id)
)
.where(
sportsSeasonIds.length === 1
? eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonIds[0])
: inArray(schema.seasonParticipants.sportsSeasonId, sportsSeasonIds)
);
return participants;
}
export async function getDraftParticipants(seasonId: string) {
const db = database();
const seasonSports = await db.query.seasonSports.findMany({
where: eq(schema.seasonSports.seasonId, seasonId),
});
const sportsSeasonIds = seasonSports.map((ss) => ss.sportsSeasonId);
if (sportsSeasonIds.length === 0) return [];
return await db
.select({
id: schema.seasonParticipants.id,
name: schema.seasonParticipants.name,
vorpValue: schema.seasonParticipants.vorpValue,
sport: schema.sports,
})
.from(schema.seasonParticipants)
.innerJoin(schema.sportsSeasons, eq(schema.seasonParticipants.sportsSeasonId, schema.sportsSeasons.id))
.innerJoin(schema.sports, eq(schema.sportsSeasons.sportId, schema.sports.id))
.where(
sportsSeasonIds.length === 1
? eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonIds[0])
: inArray(schema.seasonParticipants.sportsSeasonId, sportsSeasonIds)
)
.orderBy(desc(schema.seasonParticipants.vorpValue), asc(schema.seasonParticipants.name));
}