import { database } from "~/database/context"; import { eq, isNull, or } from "drizzle-orm"; import * as schema from "~/database/schema"; import { findParticipantsBySportsSeasonId, updateParticipant } from "~/models/season-participant"; import { upsertSeasonMatchBulk, upsertMatchSubGame } from "~/models/season-match"; import { setMatchWinner, updatePlayoffMatch, advanceWinnerTemplate, findPlayoffMatchesByEventId } from "~/models/playoff-match"; import { processMatchResult, autoCompleteRoundIfDone } from "~/models/scoring-calculator"; import { findMatchingTeamName } from "~/lib/normalize-team-name"; import { getBracketTemplate } from "~/lib/bracket-templates"; import { PandaScoreMatchSyncAdapter } from "./pandascore"; import { EspnScheduleAdapter } from "./espn-schedule"; import type { MatchSyncAdapter, MatchSyncResult, MatchRecord } from "./types"; import { logger } from "~/lib/logger"; function getMatchSyncAdapter(simulatorType: string): MatchSyncAdapter { switch (simulatorType) { case "cs2_major_qualifying_points": return new PandaScoreMatchSyncAdapter(); case "mlb_bracket": return new EspnScheduleAdapter("baseball/mlb"); case "nba_bracket": return new EspnScheduleAdapter("basketball/nba"); case "mls_bracket": return new EspnScheduleAdapter("soccer/mls"); case "wnba_bracket": return new EspnScheduleAdapter("basketball/wnba"); case "nhl_bracket": return new EspnScheduleAdapter("hockey/nhl"); default: throw new Error( `No match sync adapter available for simulator type "${simulatorType}". ` + "CS2, MLB, NBA, MLS, WNBA, and NHL are currently supported." ); } } export async function syncMatches(sportsSeasonId: string): Promise { const db = database(); 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 externalSeasonId = sportsSeason.externalSeasonId; if (!externalSeasonId) { throw new Error(`Sports season "${sportsSeason.name}" has no externalSeasonId configured`); } const simulatorType = sportsSeason.sport?.simulatorType; if (!simulatorType) { throw new Error(`Sport "${sportsSeason.sport?.name ?? "(none)"}" has no simulator type configured`); } const adapter = getMatchSyncAdapter(simulatorType); const fetchedMatches = await adapter.fetchMatches(externalSeasonId); // Load participants, build lookup maps const participants = await findParticipantsBySportsSeasonId(sportsSeasonId); const participantNames = participants.map((p) => p.name); const participantByExternalId = new Map( participants.filter((p) => p.externalId).map((p) => [p.externalId!, p]) ); const participantByName = new Map(participants.map((p) => [p.name, p])); async function resolveParticipant(externalId: string, name: string) { let participant = participantByExternalId.get(externalId) ?? null; if (!participant) { const matchedName = findMatchingTeamName(name, participantNames); if (matchedName) { participant = participantByName.get(matchedName) ?? null; if (participant && !participant.externalId) { await updateParticipant(participant.id, { externalId }); participantByExternalId.set(externalId, participant); } } } return participant; } // Find the scoring event for this sports season (needed for CS2 stage lookup + playoff sync) const scoringEvents = await db.query.scoringEvents.findMany({ where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), }); // Split matches: Swiss (matchStage set) vs playoff bracket (matchStage null) const swissMatches = fetchedMatches.filter((m) => m.matchStage != null); const bracketMatches = fetchedMatches.filter((m) => m.matchStage == null); const unmatchedTeams: MatchSyncResult["unmatchedTeams"] = []; const errors: MatchSyncResult["errors"] = []; let swissCreated = 0; let swissUpdated = 0; let playoffUpdated = 0; // --- Swiss matches → season_matches --- if (swissMatches.length > 0) { // For CS2, use the scoring event that has cs2_major_stage_results const cs2Event = scoringEvents.find((e) => e.isQualifyingEvent) ?? scoringEvents[0] ?? null; const toUpsert = []; for (const m of swissMatches) { const p1 = await resolveParticipant(m.team1ExternalId, m.team1Name); const p2 = await resolveParticipant(m.team2ExternalId, m.team2Name); if (!p1) unmatchedTeams.push({ externalId: m.team1ExternalId, name: m.team1Name }); if (!p2) unmatchedTeams.push({ externalId: m.team2ExternalId, name: m.team2Name }); const winnerParticipant = m.winnerExternalId ? (await resolveParticipant(m.winnerExternalId, "")) : null; toUpsert.push({ sportsSeasonId, scoringEventId: cs2Event?.id ?? null, participant1Id: p1?.id ?? null, participant2Id: p2?.id ?? null, winnerId: winnerParticipant?.id ?? null, participant1Score: m.team1Score, participant2Score: m.team2Score, matchStage: m.matchStage ?? null, matchRound: m.matchRound ?? null, matchday: m.matchday ?? null, isSeries: m.isSeries ?? false, status: m.status, scheduledAt: m.scheduledAt, startedAt: m.startedAt, completedAt: m.completedAt, externalMatchId: m.externalMatchId, }); } const upserted = await upsertSeasonMatchBulk(toUpsert); // Track created vs updated (rough heuristic: if completedAt is within last 2 min, it's new) swissCreated = upserted.filter((r) => { const diff = r.createdAt ? Date.now() - r.createdAt.getTime() : Infinity; return diff < 120_000; }).length; swissUpdated = upserted.length - swissCreated; // Upsert sub-games (maps) for matches with subGames for (const m of swissMatches) { if (!m.subGames?.length) continue; const upsertedMatch = upserted.find((u) => u.externalMatchId === m.externalMatchId); if (!upsertedMatch) continue; for (const g of m.subGames) { const winnerParticipant = g.winnerExternalId ? (await resolveParticipant(g.winnerExternalId, "")) : null; try { await upsertMatchSubGame({ seasonMatchId: upsertedMatch.id, gameNumber: g.gameNumber, gameLabel: g.gameLabel ?? null, participant1Score: g.team1Score, participant2Score: g.team2Score, winnerId: winnerParticipant?.id ?? null, status: g.status, externalGameId: g.externalGameId ?? null, }); } catch (err) { logger.error(`[match-sync] Error upserting sub-game ${g.gameNumber} for match ${m.externalMatchId}:`, err); } } } } // --- Bracket matches → playoff_matches --- if (bracketMatches.length > 0) { for (const event of scoringEvents) { const existingPlayoffMatches = await findPlayoffMatchesByEventId(event.id); if (existingPlayoffMatches.length === 0) continue; const bracketTemplate = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null; for (const m of bracketMatches) { if (m.status !== "complete") continue; const p1 = await resolveParticipant(m.team1ExternalId, m.team1Name); const p2 = await resolveParticipant(m.team2ExternalId, m.team2Name); if (!p1 || !p2) continue; const winnerParticipant = m.winnerExternalId ? await resolveParticipant(m.winnerExternalId, "") : null; if (!winnerParticipant) continue; const loserId = winnerParticipant.id === p1.id ? p2.id : p1.id; // Find the matching playoff_matches row: prefer external_match_id match, fall back to participant ID match let playoffMatch = existingPlayoffMatches.find( (pm) => pm.externalMatchId === m.externalMatchId ); if (!playoffMatch) { playoffMatch = existingPlayoffMatches.find( (pm) => !pm.isComplete && ((pm.participant1Id === p1.id && pm.participant2Id === p2.id) || (pm.participant1Id === p2.id && pm.participant2Id === p1.id)) ); } if (!playoffMatch) continue; if (playoffMatch.isComplete && playoffMatch.winnerId) continue; // idempotent try { await setMatchWinner( playoffMatch.id, winnerParticipant.id, loserId, winnerParticipant.id === p1.id ? (m.team1Score ?? undefined) : (m.team2Score ?? undefined), winnerParticipant.id === p1.id ? (m.team2Score ?? undefined) : (m.team1Score ?? undefined) ); // Set external_match_id for future upserts if (!playoffMatch.externalMatchId) { await updatePlayoffMatch(playoffMatch.id, { externalMatchId: m.externalMatchId }); } if (bracketTemplate) { try { await advanceWinnerTemplate(playoffMatch.id, winnerParticipant.id, bracketTemplate); } catch (err) { if (!(err instanceof Error && err.message.includes("already filled"))) throw err; } } const roundIsScoring = bracketTemplate?.rounds.find( (r) => r.name === playoffMatch!.round )?.isScoring; await processMatchResult({ round: playoffMatch.round, winnerId: winnerParticipant.id, loserId, isScoring: roundIsScoring !== undefined ? roundIsScoring : (playoffMatch.isScoring ?? true), sportsSeasonId, bracketTemplateId: event.bracketTemplateId ?? null, eventId: event.id, eventName: event.name ?? undefined, matchId: playoffMatch.id, loserAdvances: false, }); await autoCompleteRoundIfDone(event.id, playoffMatch.round, sportsSeasonId, db); playoffUpdated++; } catch (err) { logger.error(`[match-sync] Error processing playoff match ${m.externalMatchId}:`, err); errors.push({ externalMatchId: m.externalMatchId, error: err instanceof Error ? err.message : String(err), }); } } } } return { swissCreated, swissUpdated, playoffUpdated, unmatchedTeams, errors }; }