Migrate participant_golf_skills from per-season to canonical table (one row per real-world player), mirroring the existing participant_surface_elos pattern. Adds admin UI to copy participants between same-sport seasons with EV stubs, transactional writes, and comprehensive tests.
188 lines
6.6 KiB
TypeScript
188 lines
6.6 KiB
TypeScript
/**
|
|
* Golf Skills model (canonical).
|
|
*
|
|
* Golf skills are stored in the canonical `participant_golf_skills` table,
|
|
* keyed by canonical participant id. The admin UI still works in terms of
|
|
* season_participants — we join through season_participants → canonical
|
|
* participant → canonical golf skills so the UI doesn't need to know about
|
|
* the canonical layer.
|
|
*
|
|
* Mirrors the surface-elo.ts pattern exactly.
|
|
*/
|
|
|
|
import { database } from "~/database/context";
|
|
import { participantGolfSkills, seasonParticipants } from "~/database/schema";
|
|
import { eq, inArray, sql } from "drizzle-orm";
|
|
|
|
export interface GolfSkillsRecord {
|
|
id: string;
|
|
participantId: string;
|
|
sportsSeasonId: string;
|
|
sgTotal: number | null;
|
|
datagolfRank: number | null;
|
|
mastersOdds: number | null;
|
|
usOpenOdds: number | null;
|
|
openChampionshipOdds: number | null;
|
|
pgaChampionshipOdds: number | null;
|
|
updatedAt: Date;
|
|
}
|
|
|
|
export interface GolfSkillsWithName extends GolfSkillsRecord {
|
|
participantName: string;
|
|
}
|
|
|
|
export interface GolfSkillsInput {
|
|
participantId: string;
|
|
sgTotal?: number | null;
|
|
datagolfRank?: number | null;
|
|
mastersOdds?: number | null;
|
|
usOpenOdds?: number | null;
|
|
openChampionshipOdds?: number | null;
|
|
pgaChampionshipOdds?: number | null;
|
|
}
|
|
|
|
/**
|
|
* Load golf skills for a sports season's roster, joined with the per-window
|
|
* participant's name. Internally joins season_participants → canonical
|
|
* participants → canonical participant_golf_skills.
|
|
*
|
|
* The returned `id` is the canonical `participant_golf_skills.id`;
|
|
* `participantId` is the season_participant id (admin UI keys rows by that).
|
|
*/
|
|
export async function getGolfSkillsForSeason(
|
|
sportsSeasonId: string,
|
|
): Promise<GolfSkillsWithName[]> {
|
|
const db = database();
|
|
const rows = await db
|
|
.select({
|
|
id: participantGolfSkills.id,
|
|
participantId: seasonParticipants.id,
|
|
sportsSeasonId: seasonParticipants.sportsSeasonId,
|
|
sgTotal: participantGolfSkills.sgTotal,
|
|
datagolfRank: participantGolfSkills.datagolfRank,
|
|
mastersOdds: participantGolfSkills.mastersOdds,
|
|
usOpenOdds: participantGolfSkills.usOpenOdds,
|
|
openChampionshipOdds: participantGolfSkills.openChampionshipOdds,
|
|
pgaChampionshipOdds: participantGolfSkills.pgaChampionshipOdds,
|
|
updatedAt: participantGolfSkills.updatedAt,
|
|
participantName: seasonParticipants.name,
|
|
})
|
|
.from(seasonParticipants)
|
|
.leftJoin(
|
|
participantGolfSkills,
|
|
eq(participantGolfSkills.participantId, seasonParticipants.participantId),
|
|
)
|
|
.where(eq(seasonParticipants.sportsSeasonId, sportsSeasonId))
|
|
.orderBy(seasonParticipants.name);
|
|
|
|
return rows
|
|
.filter((r): r is typeof r & { id: string; updatedAt: Date } => r.id !== null)
|
|
.map((r) => ({
|
|
...r,
|
|
sgTotal: r.sgTotal !== null ? Number(r.sgTotal) : null,
|
|
})) as GolfSkillsWithName[];
|
|
}
|
|
|
|
/**
|
|
* Returns a Map from season_participant.id → GolfSkillsRecord for use in the simulator.
|
|
* Internally joins through canonical participants to read from the canonical table.
|
|
* Participants with no canonical link or no canonical golf skills record are absent
|
|
* from the map (simulator falls back to SG = 0).
|
|
*/
|
|
export async function getGolfSkillsMap(
|
|
sportsSeasonId: string,
|
|
): Promise<Map<string, GolfSkillsRecord>> {
|
|
const db = database();
|
|
const rows = await db
|
|
.select({
|
|
seasonParticipantId: seasonParticipants.id,
|
|
sportsSeasonId: seasonParticipants.sportsSeasonId,
|
|
id: participantGolfSkills.id,
|
|
sgTotal: participantGolfSkills.sgTotal,
|
|
datagolfRank: participantGolfSkills.datagolfRank,
|
|
mastersOdds: participantGolfSkills.mastersOdds,
|
|
usOpenOdds: participantGolfSkills.usOpenOdds,
|
|
openChampionshipOdds: participantGolfSkills.openChampionshipOdds,
|
|
pgaChampionshipOdds: participantGolfSkills.pgaChampionshipOdds,
|
|
updatedAt: participantGolfSkills.updatedAt,
|
|
})
|
|
.from(seasonParticipants)
|
|
.innerJoin(
|
|
participantGolfSkills,
|
|
eq(participantGolfSkills.participantId, seasonParticipants.participantId),
|
|
)
|
|
.where(eq(seasonParticipants.sportsSeasonId, sportsSeasonId));
|
|
|
|
return new Map(rows.map((r) => [
|
|
r.seasonParticipantId,
|
|
{
|
|
id: r.id,
|
|
participantId: r.seasonParticipantId,
|
|
sportsSeasonId: r.sportsSeasonId,
|
|
sgTotal: r.sgTotal !== null ? Number(r.sgTotal) : null,
|
|
datagolfRank: r.datagolfRank,
|
|
mastersOdds: r.mastersOdds,
|
|
usOpenOdds: r.usOpenOdds,
|
|
openChampionshipOdds: r.openChampionshipOdds,
|
|
pgaChampionshipOdds: r.pgaChampionshipOdds,
|
|
updatedAt: r.updatedAt,
|
|
},
|
|
]));
|
|
}
|
|
|
|
/**
|
|
* Upsert golf skill ratings for a batch of season_participants. Writes to the
|
|
* canonical `participant_golf_skills` table; callers pass season_participant
|
|
* ids and we resolve canonical ids internally.
|
|
*
|
|
* season_participants without a canonical link are silently skipped. In
|
|
* practice every qualifying-points roster entry is canonical-linked via
|
|
* the auto-linking on createParticipant.
|
|
*/
|
|
export async function batchUpsertGolfSkills(
|
|
inputs: GolfSkillsInput[],
|
|
): Promise<void> {
|
|
if (inputs.length === 0) return;
|
|
const db = database();
|
|
const now = new Date();
|
|
|
|
const sps = await db
|
|
.select({ id: seasonParticipants.id, canonicalId: seasonParticipants.participantId })
|
|
.from(seasonParticipants)
|
|
.where(inArray(seasonParticipants.id, inputs.map((i) => i.participantId)));
|
|
const canonicalByInputId = new Map(sps.map((sp) => [sp.id, sp.canonicalId]));
|
|
|
|
const canonicalRows = inputs
|
|
.map((i) => ({
|
|
canonicalId: canonicalByInputId.get(i.participantId),
|
|
sgTotal: i.sgTotal !== null && i.sgTotal !== undefined ? String(i.sgTotal) : null,
|
|
datagolfRank: i.datagolfRank ?? null,
|
|
mastersOdds: i.mastersOdds ?? null,
|
|
usOpenOdds: i.usOpenOdds ?? null,
|
|
openChampionshipOdds: i.openChampionshipOdds ?? null,
|
|
pgaChampionshipOdds: i.pgaChampionshipOdds ?? null,
|
|
}))
|
|
.filter((r): r is typeof r & { canonicalId: string } => typeof r.canonicalId === "string");
|
|
|
|
if (canonicalRows.length === 0) return;
|
|
|
|
await db
|
|
.insert(participantGolfSkills)
|
|
.values(canonicalRows.map(({ canonicalId, ...rest }) => ({
|
|
participantId: canonicalId,
|
|
...rest,
|
|
updatedAt: now,
|
|
})))
|
|
.onConflictDoUpdate({
|
|
target: [participantGolfSkills.participantId],
|
|
set: {
|
|
sgTotal: sql`excluded.sg_total`,
|
|
datagolfRank: sql`excluded.datagolf_rank`,
|
|
mastersOdds: sql`excluded.masters_odds`,
|
|
usOpenOdds: sql`excluded.us_open_odds`,
|
|
openChampionshipOdds: sql`excluded.open_championship_odds`,
|
|
pgaChampionshipOdds: sql`excluded.pga_championship_odds`,
|
|
updatedAt: sql`excluded.updated_at`,
|
|
},
|
|
});
|
|
}
|