Follow-up to the QP scoring fix, from self-review: - Cloned qualifying windows carry a bracketTemplateId (copied by cloneSportsSeason) but no playoff matches, so they hit the bracket branch, where processQualifyingBracketEvent derives zero states and writes no QP — the canonical-tie-count fix never ran for them. processQualifyingEvent now routes to the bracket writer only when the window actually has playoff matches; otherwise it falls through to the placement/canonical path like any no-template sibling. - The canonical tournament_results tie span was queried once per window inside the fan-out loop. syncTournamentResults already loads the canonical results, so it now builds the tie-count map once (buildTieCountByPlacement) and passes it to each processQualifyingEvent call; direct callers still query it lazily. - syncTournamentResults no longer re-stamps completedAt on windows that are already complete, so a backfill re-run doesn't churn timestamps. Adds regression tests: a cloned window (template id, no matches) scores R16 losers at 1.5 via the canonical path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017AUcHy9m7aF6axsY6Grpe4
363 lines
14 KiB
TypeScript
363 lines
14 KiB
TypeScript
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<SyncReport> {
|
|
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<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, {
|
|
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<SyncReport> {
|
|
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<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,
|
|
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<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}`
|
|
);
|
|
}
|
|
}
|