import { database } from "~/database/context"; import { eq, inArray } from "drizzle-orm"; import * as schema from "~/database/schema"; import { findParticipantsBySportsSeasonId, updateParticipant } from "~/models/season-participant"; import { upsertRegularSeasonStandings } from "~/models/regular-season-standings"; import { upsertPendingStandingsMappings } from "~/models/pending-standings-mappings"; import { upsertSeasonResultsBulk } from "~/models/participant-season-result"; import { findMatchingTeamName } from "~/lib/normalize-team-name"; import { NhlStandingsAdapter } from "./nhl"; import { NbaStandingsAdapter } from "./nba"; import { AflStandingsAdapter } from "./afl"; import { MlbStandingsAdapter } from "./mlb"; import { WnbaStandingsAdapter } from "./wnba"; import { EplStandingsAdapter } from "./epl"; import { MlsStandingsAdapter } from "./mls"; import { F1StandingsAdapter, IndyCarStandingsAdapter } from "./f1"; import type { StandingsSyncAdapter, SyncResult, UnmatchedTeam } from "./types"; function getAdapter(simulatorType: string): StandingsSyncAdapter { switch (simulatorType) { case "nba_bracket": return new NbaStandingsAdapter(); case "nhl_bracket": return new NhlStandingsAdapter(); case "afl_bracket": return new AflStandingsAdapter(); case "epl_standings": return new EplStandingsAdapter(); case "mls_bracket": return new MlsStandingsAdapter(); case "mlb_bracket": return new MlbStandingsAdapter(); case "wnba_bracket": return new WnbaStandingsAdapter(); case "f1_standings": return new F1StandingsAdapter(); case "indycar_standings": return new IndyCarStandingsAdapter(); default: throw new Error( `No standings sync adapter available for simulator type "${simulatorType}". ` + "NBA, NHL, AFL, MLB, WNBA, EPL, MLS, F1, and IndyCar are currently supported." ); } } /** * Sync current standings from the appropriate external API for a sports season. * Matches API team names to participants by name and bulk-upserts the results. * * For season_standings sports (F1, IndyCar), upserts into participantSeasonResults. * For all other sports, upserts into regularSeasonStandings. * * Returns the count of synced entries and any API names that couldn't be matched. */ export async function syncStandings(sportsSeasonId: string): Promise { const db = database(); // Load sports season with sport relation const sportsSeason = await db.query.sportsSeasons.findFirst({ where: eq(schema.sportsSeasons.id, sportsSeasonId), with: { sport: true }, }); if (!sportsSeason) { throw new Error(`Sports season ${sportsSeasonId} not found`); } const simulatorType = sportsSeason.sport?.simulatorType; if (!simulatorType) { const sportName = sportsSeason.sport?.name ?? "(no sport linked)"; throw new Error(`Sport "${sportName}" has no simulator type configured`); } const isSeasonStandings = sportsSeason.scoringPattern === "season_standings"; const adapter = getAdapter(simulatorType); const fetchedRecords = await adapter.fetchStandings(); // Load all participants for this season const participants = await findParticipantsBySportsSeasonId(sportsSeasonId); const participantNames = participants.map((p) => p.name); const participantByName = new Map(participants.map((p) => [p.name, p])); const participantByExternalId = new Map( participants.filter((p) => p.externalId).map((p) => [p.externalId ?? "", p]) ); type RegularUpsertRow = Parameters[0][number]; type SeasonResultRow = Parameters[0][number]; const toUpsertRegular: RegularUpsertRow[] = []; const toUpsertSeason: SeasonResultRow[] = []; const matchedParticipantIds: string[] = []; const unmatchedRecords: typeof fetchedRecords = []; for (const record of fetchedRecords) { let participant = participantByExternalId.get(record.externalTeamId) ?? null; if (!participant) { const matchedName = findMatchingTeamName(record.teamName, participantNames); if (matchedName) { participant = participantByName.get(matchedName) ?? null; if (participant && !participant.externalId) { await updateParticipant(participant.id, { externalId: record.externalTeamId }); } } } if (!participant) { unmatchedRecords.push(record); continue; } matchedParticipantIds.push(participant.id); if (isSeasonStandings) { toUpsertSeason.push({ participantId: participant.id, sportsSeasonId, currentPoints: record.currentPoints ?? 0, currentPosition: record.leagueRank, }); } else { toUpsertRegular.push({ participantId: participant.id, sportsSeasonId, wins: record.wins, losses: record.losses, otLosses: record.otLosses ?? null, ties: record.ties ?? null, tablePoints: record.tablePoints ?? null, goalsFor: record.goalsFor ?? null, goalsAgainst: record.goalsAgainst ?? null, goalDifference: record.goalDifference ?? null, winPct: record.winPct, gamesPlayed: record.gamesPlayed, gamesBack: record.gamesBack ?? null, conference: record.conference ?? null, division: record.division ?? null, conferenceRank: record.conferenceRank ?? null, divisionRank: record.divisionRank ?? null, leagueRank: record.leagueRank, streak: record.streak ?? null, lastTen: record.lastTen ?? null, homeRecord: record.homeRecord ?? null, awayRecord: record.awayRecord ?? null, externalTeamId: record.externalTeamId, srs: record.srs ?? null, syncedAt: new Date(), }); } } let changed = false; if (isSeasonStandings && toUpsertSeason.length > 0) { const currentRows = await db .select({ participantId: schema.participantSeasonResults.participantId, currentPoints: schema.participantSeasonResults.currentPoints, currentPosition: schema.participantSeasonResults.currentPosition, }) .from(schema.participantSeasonResults) .where(inArray(schema.participantSeasonResults.participantId, matchedParticipantIds)); const currentByParticipant = new Map(currentRows.map((r) => [r.participantId, r])); changed = toUpsertSeason.some((row) => { const current = currentByParticipant.get(row.participantId); if (!current) return true; return ( current.currentPosition !== row.currentPosition || parseFloat(current.currentPoints ?? "0") !== (row.currentPoints ?? 0) ); }); await upsertSeasonResultsBulk(toUpsertSeason); if (changed) { await db .update(schema.sportsSeasons) .set({ standingsLastChangedAt: new Date() }) .where(eq(schema.sportsSeasons.id, sportsSeasonId)); } } else if (!isSeasonStandings && toUpsertRegular.length > 0) { const currentRows = await db .select({ participantId: schema.regularSeasonStandings.participantId, gamesPlayed: schema.regularSeasonStandings.gamesPlayed, leagueRank: schema.regularSeasonStandings.leagueRank, }) .from(schema.regularSeasonStandings) .where(inArray(schema.regularSeasonStandings.participantId, matchedParticipantIds)); const currentByParticipant = new Map(currentRows.map((r) => [r.participantId, r])); changed = toUpsertRegular.some((row) => { const current = currentByParticipant.get(row.participantId); if (!current) return true; return current.gamesPlayed !== row.gamesPlayed || current.leagueRank !== row.leagueRank; }); await upsertRegularSeasonStandings(toUpsertRegular); if (changed) { await db .update(schema.sportsSeasons) .set({ standingsLastChangedAt: new Date() }) .where(eq(schema.sportsSeasons.id, sportsSeasonId)); } } if (unmatchedRecords.length > 0) { await upsertPendingStandingsMappings(sportsSeasonId, unmatchedRecords); } const unmatched: UnmatchedTeam[] = unmatchedRecords.map((r) => ({ teamName: r.teamName, externalTeamId: r.externalTeamId, })); const synced = isSeasonStandings ? toUpsertSeason.length : toUpsertRegular.length; return { synced, unmatched, changed }; }