import { database } from "~/database/context"; import { eq } from "drizzle-orm"; import * as schema from "~/database/schema"; import { findParticipantsBySportsSeasonId, updateParticipant } from "~/models/participant"; import { upsertRegularSeasonStandings } from "~/models/regular-season-standings"; import { upsertPendingStandingsMappings } from "~/models/pending-standings-mappings"; import { findMatchingTeamName } from "~/lib/normalize-team-name"; import { NhlStandingsAdapter } from "./nhl"; import { NbaStandingsAdapter } from "./nba"; import { AflStandingsAdapter } from "./afl"; import type { StandingsSyncAdapter, SyncResult, UnmatchedTeam } from "./types"; /** * Map a simulator type to its standings sync adapter. * Returns null for sports that don't use regularSeasonStandings * (e.g. season_standings sports like F1 will use a different path when implemented). */ 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 "f1_standings": throw new Error( "F1 standings sync is not yet implemented. Use the manual standings page." ); case "indycar_standings": throw new Error( "IndyCar standings sync is not yet implemented. Use the manual standings page." ); default: throw new Error( `No standings sync adapter available for simulator type "${simulatorType}". ` + "Only NBA and NHL 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. * * Returns the count of synced teams and any API team 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 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])); // Build a map of externalId → participant for ID-first matching const participantByExternalId = new Map( participants.filter((p) => p.externalId).map((p) => [p.externalId ?? "", p]) ); const toUpsert: Parameters[0] = []; const unmatchedRecords: typeof fetchedRecords = []; for (const record of fetchedRecords) { // Strategy A: try externalId-first (bypasses name matching entirely after first sync) let participant = participantByExternalId.get(record.externalTeamId) ?? null; if (!participant) { // Fall back to name matching const matchedName = findMatchingTeamName(record.teamName, participantNames); if (matchedName) { participant = participantByName.get(matchedName) ?? null; // Write-back: persist externalId so future syncs skip name matching for this team if (participant && !participant.externalId) { await updateParticipant(participant.id, { externalId: record.externalTeamId }); } } } if (!participant) { unmatchedRecords.push(record); continue; } toUpsert.push({ participantId: participant.id, sportsSeasonId, wins: record.wins, losses: record.losses, otLosses: record.otLosses ?? null, ties: record.ties ?? 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, syncedAt: new Date(), }); } if (toUpsert.length > 0) { await upsertRegularSeasonStandings(toUpsert); } // Persist unmatched records so admin can resolve them without losing data on page reload if (unmatchedRecords.length > 0) { await upsertPendingStandingsMappings(sportsSeasonId, unmatchedRecords); } const unmatched: UnmatchedTeam[] = unmatchedRecords.map((r) => ({ teamName: r.teamName, externalTeamId: r.externalTeamId, })); return { synced: toUpsert.length, unmatched }; }