Simplify matchLabel to just the bare "P1 vs P2" matchup (round/game number is redundant with the event name and sport already shown), extract the participant-name batch lookup into a shared findParticipantNamesByIds helper reused by team-score-events, run it concurrently with the max-game-number lookup, and cover the no-games-scheduled-yet and partial-name-resolution cases with tests. https://claude.ai/code/session_01NpQNGWacjg7mbnq8aaUweJ
358 lines
11 KiB
TypeScript
358 lines
11 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)],
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Batch-resolve participant names by id. Used wherever a set of season
|
|
* participant ids (e.g. from a bracket match or a stored array of ids) needs
|
|
* to be displayed by name without N+1 lookups.
|
|
*/
|
|
export async function findParticipantNamesByIds(
|
|
db: ReturnType<typeof database>,
|
|
ids: string[]
|
|
): Promise<Map<string, string>> {
|
|
const nameById = new Map<string, string>();
|
|
if (ids.length === 0) return nameById;
|
|
|
|
const rows = await db.query.seasonParticipants.findMany({
|
|
where: inArray(schema.seasonParticipants.id, ids),
|
|
columns: { id: true, name: true },
|
|
});
|
|
for (const row of rows) {
|
|
nameById.set(row.id, row.name);
|
|
}
|
|
return nameById;
|
|
}
|
|
|
|
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 interface CopyParticipantsResult {
|
|
copied: number;
|
|
skipped: number;
|
|
evStubsCopied: number;
|
|
}
|
|
|
|
/**
|
|
* Copy participants from one sports season to another, skipping duplicates by
|
|
* name. Also copies EV stubs (sourceOdds, sourceElo, worldRanking) so that
|
|
* running a simulation immediately after copying produces differentiated
|
|
* results.
|
|
*
|
|
* Canonical skill data (golf SG:Total, tennis surface Elos) is automatically
|
|
* available through the canonical participant link — no copying needed.
|
|
*/
|
|
export async function copyParticipantsFromSeason(
|
|
sourceSportsSeasonId: string,
|
|
targetSportsSeasonId: string,
|
|
): Promise<CopyParticipantsResult> {
|
|
const db = database();
|
|
|
|
const targetParticipants = await findParticipantsBySportsSeasonId(targetSportsSeasonId);
|
|
const targetNames = new Set(targetParticipants.map((p) => p.name.toLowerCase()));
|
|
|
|
const sourceParticipants = await findParticipantsBySportsSeasonId(sourceSportsSeasonId);
|
|
const toCopy = sourceParticipants.filter((p) => !targetNames.has(p.name.toLowerCase()));
|
|
|
|
if (toCopy.length === 0) {
|
|
return { copied: 0, skipped: sourceParticipants.length, evStubsCopied: 0 };
|
|
}
|
|
|
|
const sourceEvRows = await db.query.seasonParticipantExpectedValues.findMany({
|
|
where: eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sourceSportsSeasonId),
|
|
});
|
|
const sourceEvByKey = new Map(sourceEvRows.filter((r) => r.sourceOdds !== null || r.sourceElo !== null).map((r) => {
|
|
const sp = sourceParticipants.find((p) => p.id === r.participantId);
|
|
return sp ? [sp.externalId ?? sp.name, r] as const : null;
|
|
}).filter((x): x is NonNullable<typeof x> => x !== null));
|
|
|
|
return await db.transaction(async (tx) => {
|
|
const newParticipants = await tx
|
|
.insert(schema.seasonParticipants)
|
|
.values(
|
|
toCopy.map((p) => ({
|
|
sportsSeasonId: targetSportsSeasonId,
|
|
name: p.name,
|
|
shortName: p.shortName,
|
|
externalId: p.externalId,
|
|
participantId: p.participantId,
|
|
})),
|
|
)
|
|
.returning();
|
|
|
|
let evStubsCopied = 0;
|
|
if (sourceEvByKey.size > 0 && newParticipants.length > 0) {
|
|
const now = new Date();
|
|
const evRows = newParticipants
|
|
.map((newP) => {
|
|
const key = newP.externalId ?? newP.name;
|
|
const sourceEv = sourceEvByKey.get(key);
|
|
if (!sourceEv) return null;
|
|
return {
|
|
participantId: newP.id,
|
|
sportsSeasonId: targetSportsSeasonId,
|
|
probFirst: "0", probSecond: "0", probThird: "0", probFourth: "0",
|
|
probFifth: "0", probSixth: "0", probSeventh: "0", probEighth: "0",
|
|
expectedValue: "0",
|
|
source: sourceEv.source,
|
|
sourceOdds: sourceEv.sourceOdds,
|
|
sourceElo: sourceEv.sourceElo,
|
|
worldRanking: sourceEv.worldRanking,
|
|
calculatedAt: now,
|
|
updatedAt: now,
|
|
};
|
|
})
|
|
.filter((r): r is NonNullable<typeof r> => r !== null);
|
|
|
|
if (evRows.length > 0) {
|
|
await tx.insert(schema.seasonParticipantExpectedValues).values(evRows);
|
|
evStubsCopied = evRows.length;
|
|
}
|
|
}
|
|
|
|
return {
|
|
copied: newParticipants.length,
|
|
skipped: sourceParticipants.length - toCopy.length,
|
|
evStubsCopied,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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));
|
|
}
|