import { eq, and, inArray } from "drizzle-orm"; import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { processQualifyingEvent, recalculateAffectedLeagues, buildTieCountByPlacement, } from "~/models/scoring-calculator"; import { completeScoringEvent, getScoringEventById } from "~/models/scoring-event"; import { getEventResults } from "~/models/event-result"; import { upsertTournamentResult } from "~/models/tournament-result"; import { logger } from "~/lib/logger"; export interface SyncReport { tournamentId: string; windowsSynced: number; windowsFailed: number; failures: Array<{ scoringEventId: string; sportsSeasonId: string; error: string; }>; } export interface SyncOptions { /** * Mark each synced window's scoring_event as complete (is_complete + * completed_at). Use for final results (golf batch entry, finalize-bracket). * Leave false for mid-tournament fan-out (e.g. live bracket rounds) so a major * isn't marked complete before it actually finishes. * @default true */ markComplete?: boolean; /** * Skip the primary window when fanning out from a bracket/stage major — the * primary is already scored in place, so re-running it would be wasted work. */ skipEventId?: string; /** * Suppress ALL Discord notifications for this fan-out (both the per-window QP * update from processQualifyingEvent and the league standings recalc). Used by * one-off backfills that re-score historical events — the QP values change * (e.g. a mis-split 2 → correct 1.5), which would otherwise re-ping every league. */ skipNotifications?: boolean; } /** * Fan out canonical tournament_results to every scoring_event window that * points at this tournament. For each linked window: * 1. Upsert event_results rows mirroring canonical placement / rawScore for * every roster participant that appears in the canonical results. * 2. Write 0-QP filler rows for roster participants who have no result. * 3. Delegate to processQualifyingEvent to compute QP. * * Each window runs in its own transaction so partial failures isolate. * Errors in one window never interrupt sync of the remaining windows. * * This service NEVER writes qualifying_points_awarded directly (except the * "0" filler for participants with no placement) — QP calculation for real * results is owned by processQualifyingEvent. */ export async function syncTournamentResults( tournamentId: string, options: SyncOptions = {} ): Promise { const db = database(); const { markComplete = true, skipEventId, skipNotifications = false } = options; const report: SyncReport = { tournamentId, windowsSynced: 0, windowsFailed: 0, failures: [], }; // 1. Load canonical results for this tournament. const canonicalResults = await db .select() .from(schema.tournamentResults) .where(eq(schema.tournamentResults.tournamentId, tournamentId)); // The full-field tie span (placement → count) is a property of the whole // tournament, so compute it once here and hand it to every window's // processQualifyingEvent instead of re-querying canonical results per window. const canonicalTieCountByPlacement = buildTieCountByPlacement(canonicalResults); // 2. Load every scoring_event that points at this tournament. const allLinkedEvents = await db .select() .from(schema.scoringEvents) .where(eq(schema.scoringEvents.tournamentId, tournamentId)); // The primary window (when fanning out from a bracket/stage major) is already // scored in place — skip it so we don't redo its work. const linkedEvents = skipEventId ? allLinkedEvents.filter((ev) => ev.id !== skipEventId) : allLinkedEvents; // Windows that synced cleanly — used to propagate to leagues after the loop // (kept out of the per-window transaction so a recalc failure can't roll back // the result write). const syncedWindows: Array<{ sportsSeasonId: string; eventId: string; eventName: string | null; }> = []; // 3. Fan out — each event gets its own transaction. for (const ev of linkedEvents) { try { await db.transaction(async (tx: typeof db) => { // 3a. Window roster = season_participants for this event's sports_season. const rosters = await tx .select() .from(schema.seasonParticipants) .where(eq(schema.seasonParticipants.sportsSeasonId, ev.sportsSeasonId)); // 3b. For each canonical result whose participant is on the roster, // upsert the matching event_results row. Only placement / rawScore // are copied; qualifying_points_awarded is left for // processQualifyingEvent to recompute. const canonicalSpIds = new Set(); for (const cr of canonicalResults) { const sp = rosters.find((r) => r.participantId === cr.participantId); if (!sp) continue; canonicalSpIds.add(sp.id); const [existing] = await tx .select() .from(schema.eventResults) .where( and( eq(schema.eventResults.scoringEventId, ev.id), eq(schema.eventResults.seasonParticipantId, sp.id) ) ); if (existing) { await tx .update(schema.eventResults) .set({ placement: cr.placement, rawScore: cr.rawScore, updatedAt: new Date(), }) .where(eq(schema.eventResults.id, existing.id)); } else { await tx.insert(schema.eventResults).values({ scoringEventId: ev.id, seasonParticipantId: sp.id, placement: cr.placement, rawScore: cr.rawScore, }); } } // 3c. Reconcile every roster member NOT in the canonical results to a // 0-QP filler row (placement=null, QP=0). This both creates missing rows // AND resets rows for participants whose canonical placement was removed // (a correction) — without this, a stale placement/rawScore would persist // and keep fanning out. notParticipating markers are left untouched. const afterRows = await tx .select() .from(schema.eventResults) .where(eq(schema.eventResults.scoringEventId, ev.id)); const existingBySp = new Map(afterRows.map((r) => [r.seasonParticipantId, r])); for (const sp of rosters) { if (canonicalSpIds.has(sp.id)) continue; const existing = existingBySp.get(sp.id); if (!existing) { await tx.insert(schema.eventResults).values({ scoringEventId: ev.id, seasonParticipantId: sp.id, placement: null, qualifyingPointsAwarded: "0", }); } else if (!existing.notParticipating && existing.placement !== null) { // Stale placement from a prior sync — reset to filler. await tx .update(schema.eventResults) .set({ placement: null, rawScore: null, qualifyingPointsAwarded: "0", updatedAt: new Date(), }) .where(eq(schema.eventResults.id, existing.id)); } } // 3d. Delegate to scoring engine inside the same transaction. await processQualifyingEvent(ev.id, tx, { skipNotifications, canonicalTieCountByPlacement, }); // 3e. Mark the window event complete (final-results sync only). This is // what makes "score once" actually complete every window — without it, // each sibling stayed "In Progress" and had to be completed by hand. // Skip windows already complete so a re-run (e.g. a backfill) doesn't // needlessly re-stamp completedAt. if (markComplete && !ev.isComplete) { await completeScoringEvent(ev.id, tx); } }); report.windowsSynced += 1; syncedWindows.push({ sportsSeasonId: ev.sportsSeasonId, eventId: ev.id, eventName: ev.name, }); } catch (e: unknown) { report.windowsFailed += 1; const msg = e instanceof Error ? e.message : typeof e === "string" ? e : String(e); report.failures.push({ scoringEventId: ev.id, sportsSeasonId: ev.sportsSeasonId, error: msg, }); } } // 4. Propagate to leagues for every cleanly-synced window. Done outside the // per-window transactions: recalculateAffectedLeagues touches many fantasy // seasons and must not roll back a committed result write if it fails. for (const w of syncedWindows) { try { await recalculateAffectedLeagues(w.sportsSeasonId, undefined, { eventId: w.eventId, eventName: w.eventName ?? undefined, // Mid-tournament fan-out (markComplete=false) updates standings silently; // the primary window already announced the round. Only the final sync // (completion) announces to each window's leagues. A backfill // (skipNotifications) is always silent. skipDiscord: skipNotifications || !markComplete, }); } catch (e: unknown) { const msg = e instanceof Error ? e.message : typeof e === "string" ? e : String(e); // Count this as a failed window so the caller's status derivation // (windowsFailed === 0 ? "completed") doesn't mask stale standings behind // a green badge — the result write committed, but the league is not current. report.windowsFailed += 1; report.failures.push({ scoringEventId: w.eventId, sportsSeasonId: w.sportsSeasonId, error: `League recalc failed: ${msg}`, }); } } return report; } /** * Fan out a bracket/stage major (tennis, CS2) from its primary window. * * The admin builds and scores the bracket/Swiss stages on ONE window (the * primary). Those derive per-participant placements into the primary's * event_results. This reads them back, translates each window-scoped * season_participant to its canonical participant, and persists them as the * tournament's canonical tournament_results — the exact same shape golf produces * by hand. It then runs the normal fan-out to every sibling window (skipping the * primary, which is already scored). * * markComplete: pass false while a tournament is still in progress (live rounds) * so siblings stay in sync without being marked finished; pass true on finalize. */ export async function syncMajorFromPrimaryEvent( primaryEventId: string, options: { markComplete?: boolean; skipNotifications?: boolean } = {} ): Promise { const { markComplete = false, skipNotifications = false } = options; const primaryEvent = await getScoringEventById(primaryEventId); if (!primaryEvent) { throw new Error(`Primary event ${primaryEventId} not found`); } if (!primaryEvent.tournamentId) { throw new Error( `Event ${primaryEventId} is not linked to a tournament; cannot fan out` ); } const tournamentId = primaryEvent.tournamentId; // Read the primary window's derived results and persist them as canonical // tournament_results, keyed by canonical participant. Only real placements are // promoted — unplaced/filler rows are reconstructed per-window by the fan-out. const primaryResults = await getEventResults(primaryEventId); const promotedCanonicalIds = new Set(); for (const r of primaryResults) { if (r.placement === null || r.notParticipating) continue; const canonicalParticipantId = r.seasonParticipant?.participantId; if (!canonicalParticipantId) { logger.warn( `[syncMajorFromPrimaryEvent] season_participant ${r.seasonParticipantId} has no canonical participant link; skipping` ); continue; } await upsertTournamentResult({ tournamentId, participantId: canonicalParticipantId, placement: r.placement, rawScore: r.rawScore, }); promotedCanonicalIds.add(canonicalParticipantId); } // Remove canonical rows for participants no longer placed on the primary (a // correction that dropped someone) so the stale placement stops fanning out. const db = database(); const existingCanonical = await db .select({ participantId: schema.tournamentResults.participantId }) .from(schema.tournamentResults) .where(eq(schema.tournamentResults.tournamentId, tournamentId)); const staleIds = existingCanonical .map((r) => r.participantId) .filter((id) => !promotedCanonicalIds.has(id)); if (staleIds.length > 0) { await db .delete(schema.tournamentResults) .where( and( eq(schema.tournamentResults.tournamentId, tournamentId), inArray(schema.tournamentResults.participantId, staleIds) ) ); } logger.log( `[syncMajorFromPrimaryEvent] promoted ${promotedCanonicalIds.size} result(s) from primary ${primaryEventId} to tournament ${tournamentId}; removed ${staleIds.length} stale` ); return syncTournamentResults(tournamentId, { markComplete, skipEventId: primaryEventId, skipNotifications, }); } /** * Convenience guard for route handlers: fan out a just-scored bracket/stage event * to its sibling windows ONLY when it's the designated primary of a shared * tournament. No-ops for standalone events (no tournament_id) or non-primary * windows (which are read-only and never scored directly). Failures are logged * but never thrown — fan-out must not break the admin's local scoring action. */ export async function fanOutMajorIfPrimary( event: { id: string; isPrimary: boolean; tournamentId: string | null }, options: { markComplete?: boolean } = {} ): Promise { if (!event.isPrimary || !event.tournamentId) return; try { await syncMajorFromPrimaryEvent(event.id, options); } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); logger.error( `[fanOutMajorIfPrimary] fan-out failed for primary event ${event.id}: ${msg}` ); } }