brackt/app/services/sync-tournament-results.ts
Chris Parsons 9480501932
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m12s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m24s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🧪 Test (push) Successful in 3m27s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m25s
🚀 Deploy / 🐳 Build (push) Successful in 1m14s
🚀 Deploy / 🚀 Deploy (push) Successful in 14s
Unify majors: score once, fan out across windows + tennis bracket EV
Make a "major" (golf/tennis/CS2) scored once on its canonical tournament
and fan out to every linked sports_season window and league.

Fan-out & completion (app/services/sync-tournament-results.ts):
- syncTournamentResults now marks each synced window's event complete
  (gated by markComplete), recalculates affected leagues, and counts
  recalc failures so a stale league can't hide behind a "completed" badge
- syncMajorFromPrimaryEvent promotes a primary window's derived results to
  canonical tournament_results (deleting rows for dropped placements) and
  fans out to siblings; fanOutMajorIfPrimary guards on the primary
- placement removals now propagate (stale rows reset to filler)

Primary-event model (scoring_events.is_primary, migration 0122):
- getPrimaryEventForTournament / isReadOnlySibling / ensurePrimaryEvent /
  setPrimaryEvent; event creation auto-seeds a primary for bracket majors;
  "Make primary" button on the tournament page
- per-window event/bracket/cs2 pages are read-only for non-primary linked
  events (not-participating stays editable)

Tennis Grand Slam bracket (tennis_128 template + TEMPLATE_ROUND_CONFIG):
- bracket-scored qualifying major via the existing bracket pipeline
- simulator conditions in-progress EV on the real bracket (honoring
  completed matches, walkover for withdrawals), QP derived from config,
  round structure read from the template; CS2 + tennis share resolveStructureSource

Backfill (scripts/backfill-major-linking.ts): one-time idempotent reconcile
of existing majors (link orphans, designate primary, promote canonical, sync).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:32:22 -07:00

343 lines
13 KiB
TypeScript

import { eq, and, inArray } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import {
processQualifyingEvent,
recalculateAffectedLeagues,
} 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;
}
/**
* 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<SyncReport> {
const db = database();
const { markComplete = true, skipEventId } = 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));
// 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<string>();
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);
// 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.
if (markComplete) {
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.
skipDiscord: !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 } = {}
): Promise<SyncReport> {
const { markComplete = 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<string>();
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,
});
}
/**
* 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<void> {
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}`
);
}
}