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
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>
343 lines
13 KiB
TypeScript
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}`
|
|
);
|
|
}
|
|
}
|