import { database } from "~/database/context"; import { eq } from "drizzle-orm"; import * as schema from "~/database/schema"; import { findParticipantsBySportsSeasonId, updateParticipant, createParticipant, createManyParticipants, } from "~/models/season-participant"; import { upsertSeasonMatchBulk, upsertMatchSubGame } from "~/models/season-match"; import { setMatchWinner, updatePlayoffMatch, advanceWinnerTemplate, findPlayoffMatchesByEventId, doesLoserAdvance, populateBracketFromDraw, } from "~/models/playoff-match"; import { processMatchResult, autoCompleteRoundIfDone, processQualifyingBracketEvent, recalculateAffectedLeagues, } from "~/models/scoring-calculator"; import { fanOutMajorIfPrimary } from "~/services/sync-tournament-results"; import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server"; import { recalculateParticipantQP, diffChangedQualifyingPoints, } from "~/models/qualifying-points"; import { getScoringEventById, updateScoringEvent, isReadOnlySibling } from "~/models/scoring-event"; import { findMatchingTeamName, normalizeTeamName } from "~/lib/normalize-team-name"; import { buildUnmatchedTeamResolutionView } from "~/lib/unmatched-team-reconciliation"; import { getBracketTemplate } from "~/lib/bracket-templates"; import { PandaScoreMatchSyncAdapter } from "./pandascore"; import { EspnScheduleAdapter } from "./espn-schedule"; import { WikipediaTennisAdapter } from "./wikipedia-tennis"; import { playerKey, canonicalPlayerName, buildResolvedMatches } from "./tennis-draw-mapping"; import type { MatchSyncAdapter, MatchSyncResult, DrawSyncAdapter, DrawSyncResult, DrawSyncPreview, FetchedDraw, FetchedPlayer, } 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", 3); case "nba_bracket": return new EspnScheduleAdapter("basketball/nba", 3); case "mls_bracket": return new EspnScheduleAdapter("soccer/mls", 3); case "wnba_bracket": return new EspnScheduleAdapter("basketball/wnba", 3); case "nhl_bracket": return new EspnScheduleAdapter("hockey/nhl", 3); 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 as string, 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 && m.matchStage !== undefined); const bracketMatches = fetchedMatches.filter((m) => m.matchStage === null || m.matchStage === undefined); 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 && scoringEvents.length === 0) { logger.warn(`[match-sync] ${bracketMatches.length} bracket match(es) found but no scoring events exist for season ${sportsSeasonId} — bracket sync skipped`); } 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: event.bracketTemplateId ? doesLoserAdvance(playoffMatch.round, playoffMatch.matchNumber, event.bracketTemplateId) : 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 }; } // =========================================================================== // Tennis Grand Slam draw sync // =========================================================================== function getDrawAdapter(simulatorType: string): DrawSyncAdapter { switch (simulatorType) { case "tennis_qualifying_points": return new WikipediaTennisAdapter(); default: throw new Error( `No draw sync adapter available for simulator type "${simulatorType}". ` + "Tennis is currently supported.", ); } } /** Validate the event, fetch + parse its draw. Read-only — used by sync & preview. */ async function loadTennisDraw(eventId: string) { const db = database(); const event = await getScoringEventById(eventId); if (!event) throw new Error(`Scoring event ${eventId} not found`); if (isReadOnlySibling(event)) { throw new Error( "This event is a read-only window of a shared major. Sync the draw on the primary window; " + "results fan out here automatically.", ); } if (!event.isQualifyingEvent) { throw new Error(`Event "${event.name}" is not a qualifying event; cannot sync a tennis draw.`); } const sourceKey = event.externalSourceKey; if (!sourceKey) { throw new Error( `Event "${event.name}" has no externalSourceKey (Wikipedia article title) configured.`, ); } const sportsSeason = await db.query.sportsSeasons.findFirst({ where: eq(schema.sportsSeasons.id, event.sportsSeasonId), with: { sport: true }, }); const simulatorType = sportsSeason?.sport?.simulatorType; if (!simulatorType) { throw new Error(`Sport for season ${event.sportsSeasonId} has no simulator type configured`); } const draw = await getDrawAdapter(simulatorType).fetchDraw(sourceKey); const template = getBracketTemplate(draw.templateId); if (!template) throw new Error(`Bracket template "${draw.templateId}" not found`); return { event, sportsSeasonId: event.sportsSeasonId, draw, template }; } /** Distinct players across the whole draw, keyed by stable identity. */ function collectUniquePlayers(draw: FetchedDraw): Map { const players = new Map(); for (const round of draw.rounds) { for (const match of round.matches) { for (const player of [match.player1, match.player2]) { if (player) players.set(playerKey(player), player); } } } return players; } type SeasonParticipant = Awaited>[number]; type PlayerLookups = { byExternalId: Map; byName: Map; names: string[]; recon: { id: string; name: string; externalId: string | null }[]; }; function buildLookups(participants: SeasonParticipant[]): PlayerLookups { return { byExternalId: new Map( participants.filter((p) => p.externalId).map((p) => [p.externalId as string, p]), ), byName: new Map(participants.map((p) => [p.name, p])), names: participants.map((p) => p.name), recon: participants.map((p) => ({ id: p.id, name: p.name, externalId: p.externalId })), }; } type Classification = | { kind: "matched"; participant: SeasonParticipant; backfill: boolean } | { kind: "create"; name: string; suggestion: string | null; suggestionId: string | null; }; /** Read-only: decide how a drawn player resolves against existing participants. */ function classifyPlayer(player: FetchedPlayer, lk: PlayerLookups): Classification { const name = canonicalPlayerName(player); const byId = player.externalId ? lk.byExternalId.get(player.externalId) : undefined; if (byId) return { kind: "matched", participant: byId, backfill: false }; const matchedName = findMatchingTeamName(name, lk.names); if (matchedName) { const p = lk.byName.get(matchedName); if (p) return { kind: "matched", participant: p, backfill: !p.externalId && !!player.externalId }; } // No confident match. Surface the closest existing participant (if any) as a // possible duplicate to review. const view = buildUnmatchedTeamResolutionView(name, lk.recon); const top = view.confidence !== "none" ? view.topCandidates[0] ?? null : null; return { kind: "create", name, suggestion: top?.participantName ?? null, suggestionId: top?.participantId ?? null, }; } /** * Dry run: report what a draw sync would do (matches, would-be participant * creations, likely duplicates, unfilled first-round slots) without any writes. */ export async function previewTennisDraw(eventId: string): Promise { const { event, sportsSeasonId, draw } = await loadTennisDraw(eventId); const participants = await findParticipantsBySportsSeasonId(sportsSeasonId); const lk = buildLookups(participants); const uniquePlayers = collectUniquePlayers(draw); let matched = 0; const willCreate: DrawSyncPreview["willCreate"] = []; const possibleDuplicates: DrawSyncPreview["possibleDuplicates"] = []; for (const player of uniquePlayers.values()) { const c = classifyPlayer(player, lk); if (c.kind === "matched") { matched++; } else { willCreate.push({ name: c.name, externalId: player.externalId }); if (c.suggestion && c.suggestionId) { possibleDuplicates.push({ name: c.name, externalId: player.externalId, suggestion: c.suggestion, suggestionId: c.suggestionId, }); } } } const firstRound = draw.rounds[0]; const tbdFirstRound = firstRound ? firstRound.matches.reduce((n, m) => n + (m.player1 ? 0 : 1) + (m.player2 ? 0 : 1), 0) : 0; const allMatches = draw.rounds.flatMap((r) => r.matches); return { article: event.externalSourceKey ?? "", totalPlayers: uniquePlayers.size, matched, willCreate, possibleDuplicates, tbdFirstRound, totalMatches: allMatches.length, completedMatches: allMatches.filter((m) => m.winner !== null).length, }; } /** * Populate and auto-score a tennis major's bracket from its external draw * (Wikipedia). Resolves every drawn player to a participant — matching by * external id, then name, else auto-creating — propagates new participants to * every sports season linked to the same tournament (so result fan-out lands), * writes the full bracket, and runs the qualifying-points scorer. * * Runs only on a major's primary window; read-only siblings receive results via * fan-out, not direct scoring. */ export async function syncTennisDraw(eventId: string): Promise { const db = database(); const { event, sportsSeasonId, draw, template } = await loadTennisDraw(eventId); const roundIsScoring = new Map(template.rounds.map((r) => [r.name, r.isScoring])); // ---- Resolve players → participants (primary season) ---------------------- const participants = await findParticipantsBySportsSeasonId(sportsSeasonId); const lk = buildLookups(participants); const uniquePlayers = collectUniquePlayers(draw); const keyToParticipantId = new Map(); const resolvedParticipants = new Map< string, { canonicalId: string | null; name: string; externalId: string | null } >(); const unmatched: DrawSyncResult["unmatched"] = []; let participantsCreated = 0; for (const [key, player] of uniquePlayers) { const c = classifyPlayer(player, lk); let participant: SeasonParticipant; if (c.kind === "matched") { participant = c.participant; if (c.backfill && player.externalId) { await updateParticipant(participant.id, { externalId: player.externalId }); } } else { // No confident match → auto-create so the bracket is complete; flag a // likely duplicate for admin review when it resembles an existing player. if (c.suggestion) unmatched.push({ name: c.name, externalId: player.externalId }); participant = await createParticipant({ sportsSeasonId, name: c.name, externalId: player.externalId ?? null, }); lk.names.push(participant.name); lk.byName.set(participant.name, participant); lk.recon.push({ id: participant.id, name: participant.name, externalId: participant.externalId }); if (participant.externalId) lk.byExternalId.set(participant.externalId, participant); participantsCreated++; } keyToParticipantId.set(key, participant.id); resolvedParticipants.set(key, { canonicalId: participant.participantId ?? null, name: participant.name, externalId: participant.externalId ?? null, }); } // ---- Propagate participants to linked sibling seasons --------------------- if (event.tournamentId) { const linkedRows = await db .select({ sportsSeasonId: schema.scoringEvents.sportsSeasonId }) .from(schema.scoringEvents) .where(eq(schema.scoringEvents.tournamentId, event.tournamentId)); const linkedSeasonIds = [...new Set(linkedRows.map((r) => r.sportsSeasonId))].filter( (id) => id !== sportsSeasonId, ); // Dedupe by canonical participant: two drawn players can fuzzy-match the same // existing participant, which would otherwise queue duplicate sibling inserts // and trip the (sportsSeasonId, name) unique index. const resolved = [ ...new Map( [...resolvedParticipants.values()] .filter((r) => r.canonicalId) .map((r) => [r.canonicalId, r]), ).values(), ]; for (const seasonId of linkedSeasonIds) { const sib = await findParticipantsBySportsSeasonId(seasonId); const sibCanon = new Set(sib.map((s) => s.participantId).filter(Boolean)); const sibNames = new Set(sib.map((s) => normalizeTeamName(s.name))); const toCreate = resolved .filter( (r) => r.canonicalId && !sibCanon.has(r.canonicalId) && !sibNames.has(normalizeTeamName(r.name)), ) .map((r) => ({ sportsSeasonId: seasonId, name: r.name, externalId: r.externalId, participantId: r.canonicalId, })); if (toCreate.length > 0) { await createManyParticipants(toCreate); participantsCreated += toCreate.length; } } } // ---- Write the bracket ---------------------------------------------------- const resolvedMatches = buildResolvedMatches(draw, keyToParticipantId, roundIsScoring); // Ensure the event is configured for QP bracket scoring before populating. if ( event.bracketTemplateId !== draw.templateId || event.scoringStartsAtRound !== template.scoringStartsAtRound ) { await updateScoringEvent(eventId, { bracketTemplateId: draw.templateId, scoringStartsAtRound: template.scoringStartsAtRound, }); } const { written, completed, newlyDecidedLoserIds } = await populateBracketFromDraw( eventId, resolvedMatches, ); // ---- Score (qualifying points) + fan out to siblings ---------------------- // Re-derive QP from the bracket and learn which participants' QP changed so the // Discord notification below only announces new/changed results on a re-sync. const changedParticipantIds = await rescoreTennisBracketAndDetectChanges( eventId, sportsSeasonId, db, ); await recalculateAffectedLeagues(sportsSeasonId, db, { eventId, eventName: event.name ?? undefined, }); await fanOutMajorIfPrimary( { id: event.id, isPrimary: event.isPrimary, tournamentId: event.tournamentId }, { markComplete: false }, ); // Announce QP changes for the primary window. Sibling windows are announced by // the fan-out (via processQualifyingEvent); the primary is scored directly by // processQualifyingBracketEvent, which does NOT notify — so do it here. Run // outside the rescore transaction so the webhook HTTP call neither holds the // transaction open nor rolls back the score if Discord fails. // // Also announce players knocked out this sync in a non-scoring round (rounds // 1–3 of a Grand Slam), who earn no QP and so never appear via changedParticipantIds. // Fire even when no QP changed, so an early-round elimination is still surfaced. const newlyEliminatedIds = new Set(newlyDecidedLoserIds); if (changedParticipantIds.size > 0 || newlyEliminatedIds.size > 0) { try { await notifyQualifyingPointsUpdate( sportsSeasonId, eventId, db, changedParticipantIds, newlyEliminatedIds, ); } catch (error) { logger.error(`[syncTennisDraw] QP Discord notification failed for event ${eventId}:`, error); } } return { matchesWritten: written, completed, participantsCreated, unmatched }; } /** * Re-derive a tennis major's qualifying points from its bracket and report which * season participants' awarded QP changed since the previous scoring. * * The synced draw is authoritative for tennis, so this clears ALL event_results * for the event (including any manually-entered rows, e.g. a notParticipating * withdrawal) and rebuilds them from the bracket. Change detection therefore * snapshots each participant's awarded QP *before* the delete and diffs it against * the freshly written rows: a value that differs, or a participant scored for the * first time (null → value), counts as changed. Wrapped in a transaction so a * mid-rescore failure can't leave the event with results deleted-but-not-rebuilt. * * writeEventResultsQP (inside processQualifyingBracketEvent) upserts but never * deletes, so QP totals for participants who no longer earn points (e.g. an * early-round loser dropped from the rewritten results — only Round of 16+ losers * score) are recalculated by hand. * * Returns the set of season_participant ids whose QP changed, used to scope the * QP Discord notification so a re-sync that changes nothing announces nothing. */ export async function rescoreTennisBracketAndDetectChanges( eventId: string, sportsSeasonId: string, db: ReturnType, ): Promise> { let changed = new Set(); await db.transaction(async (tx) => { const beforeRows = await tx .select({ id: schema.eventResults.seasonParticipantId, qp: schema.eventResults.qualifyingPointsAwarded, }) .from(schema.eventResults) .where(eq(schema.eventResults.scoringEventId, eventId)); await tx.delete(schema.eventResults).where(eq(schema.eventResults.scoringEventId, eventId)); await processQualifyingBracketEvent(eventId, tx); const afterRows = await tx .select({ id: schema.eventResults.seasonParticipantId, qp: schema.eventResults.qualifyingPointsAwarded, }) .from(schema.eventResults) .where(eq(schema.eventResults.scoringEventId, eventId)); const afterIds = new Set(afterRows.map((r) => r.id)); for (const { id } of beforeRows) { if (!afterIds.has(id)) await recalculateParticipantQP(id, sportsSeasonId, tx); } changed = diffChangedQualifyingPoints(beforeRows, afterRows); }); return changed; }