2026-06-12 20:46:30 +00:00
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" ;
2026-06-12 21:53:06 +00:00
import { setMatchWinner , updatePlayoffMatch , advanceWinnerTemplate , findPlayoffMatchesByEventId , doesLoserAdvance } from "~/models/playoff-match" ;
2026-06-12 20:46:30 +00:00
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" :
2026-06-12 21:53:06 +00:00
return new EspnScheduleAdapter ( "baseball/mlb" , 3 ) ;
2026-06-12 20:46:30 +00:00
case "nba_bracket" :
2026-06-12 21:53:06 +00:00
return new EspnScheduleAdapter ( "basketball/nba" , 3 ) ;
2026-06-12 20:46:30 +00:00
case "mls_bracket" :
2026-06-12 21:53:06 +00:00
return new EspnScheduleAdapter ( "soccer/mls" , 3 ) ;
2026-06-12 20:46:30 +00:00
case "wnba_bracket" :
2026-06-12 21:53:06 +00:00
return new EspnScheduleAdapter ( "basketball/wnba" , 3 ) ;
2026-06-12 20:46:30 +00:00
case "nhl_bracket" :
2026-06-12 21:53:06 +00:00
return new EspnScheduleAdapter ( "hockey/nhl" , 3 ) ;
2026-06-12 20:46:30 +00:00
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 < MatchSyncResult > {
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 ---
2026-06-12 21:53:06 +00:00
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 ` ) ;
}
2026-06-12 20:46:30 +00:00
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 ,
2026-06-12 21:53:06 +00:00
loserAdvances : event.bracketTemplateId
? doesLoserAdvance ( playoffMatch . round , playoffMatch . matchNumber , event . bracketTemplateId )
: false ,
2026-06-12 20:46:30 +00:00
} ) ;
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 } ;
}