brackt/app/services/match-sync/index.ts

272 lines
11 KiB
TypeScript
Raw Normal View History

feat: add match sync service, cron job, public tournament display (Phases 2-4) Phase 2 — Match Sync Adapters + Cron Job: - PandaScoreMatchSyncAdapter (CS2): fetches matches with map-level sub-games, stage detection - EspnScheduleAdapter (MLB/NBA/MLS/WNBA/NHL): fetches scoreboard with live scores - syncMatches() orchestrator: resolves participants by externalId+name, bulk-upserts season_matches, syncs playoff bracket results through existing setMatchWinner/processMatchResult pipeline - POST /admin/jobs/sync-matches cron endpoint (mirrors sync-and-simulate pattern) - External Season ID field added to sports season create/edit admin forms - Sync from API button wired in CS2 setup page (enabled when externalSeasonId set) Phase 3 — Public Tournament & Schedule Display: - MatchSchedule component: generic match list with live/scheduled/complete status badges, matchday grouping - Cs2TournamentBracket component: tab layout (Opening/Challengers/Legends/Champions Stage), map scores per match - /sports-seasons/:sportsSeasonId/tournament public route with 30-second live polling Phase 4 — Playoff Bracket Auto-Sync: - externalMatchId column added to playoff_matches table (migration 0121) - Bracket matches (matchStage=null) auto-synced: matches existing playoff_match rows by externalMatchId then participant IDs, calls full scoring pipeline - autoCompleteRoundIfDone extracted to scoring-calculator.ts for shared use All 2365 tests pass; typecheck clean. https://claude.ai/code/session_01WUUM7uWzFoSkGcZRhnEKG6
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";
import { setMatchWinner, updatePlayoffMatch, advanceWinnerTemplate, findPlayoffMatchesByEventId, doesLoserAdvance } from "~/models/playoff-match";
feat: add match sync service, cron job, public tournament display (Phases 2-4) Phase 2 — Match Sync Adapters + Cron Job: - PandaScoreMatchSyncAdapter (CS2): fetches matches with map-level sub-games, stage detection - EspnScheduleAdapter (MLB/NBA/MLS/WNBA/NHL): fetches scoreboard with live scores - syncMatches() orchestrator: resolves participants by externalId+name, bulk-upserts season_matches, syncs playoff bracket results through existing setMatchWinner/processMatchResult pipeline - POST /admin/jobs/sync-matches cron endpoint (mirrors sync-and-simulate pattern) - External Season ID field added to sports season create/edit admin forms - Sync from API button wired in CS2 setup page (enabled when externalSeasonId set) Phase 3 — Public Tournament & Schedule Display: - MatchSchedule component: generic match list with live/scheduled/complete status badges, matchday grouping - Cs2TournamentBracket component: tab layout (Opening/Challengers/Legends/Champions Stage), map scores per match - /sports-seasons/:sportsSeasonId/tournament public route with 30-second live polling Phase 4 — Playoff Bracket Auto-Sync: - externalMatchId column added to playoff_matches table (migration 0121) - Bracket matches (matchStage=null) auto-synced: matches existing playoff_match rows by externalMatchId then participant IDs, calls full scoring pipeline - autoCompleteRoundIfDone extracted to scoring-calculator.ts for shared use All 2365 tests pass; typecheck clean. https://claude.ai/code/session_01WUUM7uWzFoSkGcZRhnEKG6
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":
return new EspnScheduleAdapter("baseball/mlb", 3);
feat: add match sync service, cron job, public tournament display (Phases 2-4) Phase 2 — Match Sync Adapters + Cron Job: - PandaScoreMatchSyncAdapter (CS2): fetches matches with map-level sub-games, stage detection - EspnScheduleAdapter (MLB/NBA/MLS/WNBA/NHL): fetches scoreboard with live scores - syncMatches() orchestrator: resolves participants by externalId+name, bulk-upserts season_matches, syncs playoff bracket results through existing setMatchWinner/processMatchResult pipeline - POST /admin/jobs/sync-matches cron endpoint (mirrors sync-and-simulate pattern) - External Season ID field added to sports season create/edit admin forms - Sync from API button wired in CS2 setup page (enabled when externalSeasonId set) Phase 3 — Public Tournament & Schedule Display: - MatchSchedule component: generic match list with live/scheduled/complete status badges, matchday grouping - Cs2TournamentBracket component: tab layout (Opening/Challengers/Legends/Champions Stage), map scores per match - /sports-seasons/:sportsSeasonId/tournament public route with 30-second live polling Phase 4 — Playoff Bracket Auto-Sync: - externalMatchId column added to playoff_matches table (migration 0121) - Bracket matches (matchStage=null) auto-synced: matches existing playoff_match rows by externalMatchId then participant IDs, calls full scoring pipeline - autoCompleteRoundIfDone extracted to scoring-calculator.ts for shared use All 2365 tests pass; typecheck clean. https://claude.ai/code/session_01WUUM7uWzFoSkGcZRhnEKG6
2026-06-12 20:46:30 +00:00
case "nba_bracket":
return new EspnScheduleAdapter("basketball/nba", 3);
feat: add match sync service, cron job, public tournament display (Phases 2-4) Phase 2 — Match Sync Adapters + Cron Job: - PandaScoreMatchSyncAdapter (CS2): fetches matches with map-level sub-games, stage detection - EspnScheduleAdapter (MLB/NBA/MLS/WNBA/NHL): fetches scoreboard with live scores - syncMatches() orchestrator: resolves participants by externalId+name, bulk-upserts season_matches, syncs playoff bracket results through existing setMatchWinner/processMatchResult pipeline - POST /admin/jobs/sync-matches cron endpoint (mirrors sync-and-simulate pattern) - External Season ID field added to sports season create/edit admin forms - Sync from API button wired in CS2 setup page (enabled when externalSeasonId set) Phase 3 — Public Tournament & Schedule Display: - MatchSchedule component: generic match list with live/scheduled/complete status badges, matchday grouping - Cs2TournamentBracket component: tab layout (Opening/Challengers/Legends/Champions Stage), map scores per match - /sports-seasons/:sportsSeasonId/tournament public route with 30-second live polling Phase 4 — Playoff Bracket Auto-Sync: - externalMatchId column added to playoff_matches table (migration 0121) - Bracket matches (matchStage=null) auto-synced: matches existing playoff_match rows by externalMatchId then participant IDs, calls full scoring pipeline - autoCompleteRoundIfDone extracted to scoring-calculator.ts for shared use All 2365 tests pass; typecheck clean. https://claude.ai/code/session_01WUUM7uWzFoSkGcZRhnEKG6
2026-06-12 20:46:30 +00:00
case "mls_bracket":
return new EspnScheduleAdapter("soccer/mls", 3);
feat: add match sync service, cron job, public tournament display (Phases 2-4) Phase 2 — Match Sync Adapters + Cron Job: - PandaScoreMatchSyncAdapter (CS2): fetches matches with map-level sub-games, stage detection - EspnScheduleAdapter (MLB/NBA/MLS/WNBA/NHL): fetches scoreboard with live scores - syncMatches() orchestrator: resolves participants by externalId+name, bulk-upserts season_matches, syncs playoff bracket results through existing setMatchWinner/processMatchResult pipeline - POST /admin/jobs/sync-matches cron endpoint (mirrors sync-and-simulate pattern) - External Season ID field added to sports season create/edit admin forms - Sync from API button wired in CS2 setup page (enabled when externalSeasonId set) Phase 3 — Public Tournament & Schedule Display: - MatchSchedule component: generic match list with live/scheduled/complete status badges, matchday grouping - Cs2TournamentBracket component: tab layout (Opening/Challengers/Legends/Champions Stage), map scores per match - /sports-seasons/:sportsSeasonId/tournament public route with 30-second live polling Phase 4 — Playoff Bracket Auto-Sync: - externalMatchId column added to playoff_matches table (migration 0121) - Bracket matches (matchStage=null) auto-synced: matches existing playoff_match rows by externalMatchId then participant IDs, calls full scoring pipeline - autoCompleteRoundIfDone extracted to scoring-calculator.ts for shared use All 2365 tests pass; typecheck clean. https://claude.ai/code/session_01WUUM7uWzFoSkGcZRhnEKG6
2026-06-12 20:46:30 +00:00
case "wnba_bracket":
return new EspnScheduleAdapter("basketball/wnba", 3);
feat: add match sync service, cron job, public tournament display (Phases 2-4) Phase 2 — Match Sync Adapters + Cron Job: - PandaScoreMatchSyncAdapter (CS2): fetches matches with map-level sub-games, stage detection - EspnScheduleAdapter (MLB/NBA/MLS/WNBA/NHL): fetches scoreboard with live scores - syncMatches() orchestrator: resolves participants by externalId+name, bulk-upserts season_matches, syncs playoff bracket results through existing setMatchWinner/processMatchResult pipeline - POST /admin/jobs/sync-matches cron endpoint (mirrors sync-and-simulate pattern) - External Season ID field added to sports season create/edit admin forms - Sync from API button wired in CS2 setup page (enabled when externalSeasonId set) Phase 3 — Public Tournament & Schedule Display: - MatchSchedule component: generic match list with live/scheduled/complete status badges, matchday grouping - Cs2TournamentBracket component: tab layout (Opening/Challengers/Legends/Champions Stage), map scores per match - /sports-seasons/:sportsSeasonId/tournament public route with 30-second live polling Phase 4 — Playoff Bracket Auto-Sync: - externalMatchId column added to playoff_matches table (migration 0121) - Bracket matches (matchStage=null) auto-synced: matches existing playoff_match rows by externalMatchId then participant IDs, calls full scoring pipeline - autoCompleteRoundIfDone extracted to scoring-calculator.ts for shared use All 2365 tests pass; typecheck clean. https://claude.ai/code/session_01WUUM7uWzFoSkGcZRhnEKG6
2026-06-12 20:46:30 +00:00
case "nhl_bracket":
return new EspnScheduleAdapter("hockey/nhl", 3);
feat: add match sync service, cron job, public tournament display (Phases 2-4) Phase 2 — Match Sync Adapters + Cron Job: - PandaScoreMatchSyncAdapter (CS2): fetches matches with map-level sub-games, stage detection - EspnScheduleAdapter (MLB/NBA/MLS/WNBA/NHL): fetches scoreboard with live scores - syncMatches() orchestrator: resolves participants by externalId+name, bulk-upserts season_matches, syncs playoff bracket results through existing setMatchWinner/processMatchResult pipeline - POST /admin/jobs/sync-matches cron endpoint (mirrors sync-and-simulate pattern) - External Season ID field added to sports season create/edit admin forms - Sync from API button wired in CS2 setup page (enabled when externalSeasonId set) Phase 3 — Public Tournament & Schedule Display: - MatchSchedule component: generic match list with live/scheduled/complete status badges, matchday grouping - Cs2TournamentBracket component: tab layout (Opening/Challengers/Legends/Champions Stage), map scores per match - /sports-seasons/:sportsSeasonId/tournament public route with 30-second live polling Phase 4 — Playoff Bracket Auto-Sync: - externalMatchId column added to playoff_matches table (migration 0121) - Bracket matches (matchStage=null) auto-synced: matches existing playoff_match rows by externalMatchId then participant IDs, calls full scoring pipeline - autoCompleteRoundIfDone extracted to scoring-calculator.ts for shared use All 2365 tests pass; typecheck clean. https://claude.ai/code/session_01WUUM7uWzFoSkGcZRhnEKG6
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 ---
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`);
}
feat: add match sync service, cron job, public tournament display (Phases 2-4) Phase 2 — Match Sync Adapters + Cron Job: - PandaScoreMatchSyncAdapter (CS2): fetches matches with map-level sub-games, stage detection - EspnScheduleAdapter (MLB/NBA/MLS/WNBA/NHL): fetches scoreboard with live scores - syncMatches() orchestrator: resolves participants by externalId+name, bulk-upserts season_matches, syncs playoff bracket results through existing setMatchWinner/processMatchResult pipeline - POST /admin/jobs/sync-matches cron endpoint (mirrors sync-and-simulate pattern) - External Season ID field added to sports season create/edit admin forms - Sync from API button wired in CS2 setup page (enabled when externalSeasonId set) Phase 3 — Public Tournament & Schedule Display: - MatchSchedule component: generic match list with live/scheduled/complete status badges, matchday grouping - Cs2TournamentBracket component: tab layout (Opening/Challengers/Legends/Champions Stage), map scores per match - /sports-seasons/:sportsSeasonId/tournament public route with 30-second live polling Phase 4 — Playoff Bracket Auto-Sync: - externalMatchId column added to playoff_matches table (migration 0121) - Bracket matches (matchStage=null) auto-synced: matches existing playoff_match rows by externalMatchId then participant IDs, calls full scoring pipeline - autoCompleteRoundIfDone extracted to scoring-calculator.ts for shared use All 2365 tests pass; typecheck clean. https://claude.ai/code/session_01WUUM7uWzFoSkGcZRhnEKG6
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,
loserAdvances: event.bracketTemplateId
? doesLoserAdvance(playoffMatch.round, playoffMatch.matchNumber, event.bracketTemplateId)
: false,
feat: add match sync service, cron job, public tournament display (Phases 2-4) Phase 2 — Match Sync Adapters + Cron Job: - PandaScoreMatchSyncAdapter (CS2): fetches matches with map-level sub-games, stage detection - EspnScheduleAdapter (MLB/NBA/MLS/WNBA/NHL): fetches scoreboard with live scores - syncMatches() orchestrator: resolves participants by externalId+name, bulk-upserts season_matches, syncs playoff bracket results through existing setMatchWinner/processMatchResult pipeline - POST /admin/jobs/sync-matches cron endpoint (mirrors sync-and-simulate pattern) - External Season ID field added to sports season create/edit admin forms - Sync from API button wired in CS2 setup page (enabled when externalSeasonId set) Phase 3 — Public Tournament & Schedule Display: - MatchSchedule component: generic match list with live/scheduled/complete status badges, matchday grouping - Cs2TournamentBracket component: tab layout (Opening/Challengers/Legends/Champions Stage), map scores per match - /sports-seasons/:sportsSeasonId/tournament public route with 30-second live polling Phase 4 — Playoff Bracket Auto-Sync: - externalMatchId column added to playoff_matches table (migration 0121) - Bracket matches (matchStage=null) auto-synced: matches existing playoff_match rows by externalMatchId then participant IDs, calls full scoring pipeline - autoCompleteRoundIfDone extracted to scoring-calculator.ts for shared use All 2365 tests pass; typecheck clean. https://claude.ai/code/session_01WUUM7uWzFoSkGcZRhnEKG6
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 };
}