/** * 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 { 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> { 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 { 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`, }, }); }