brackt/app/services/sync-tournament-results.ts
Claude 7ca89aafc4
Fix mirrored tournaments dropping QP knockout announcements
Mirror tournament windows (leagues drafting the same real-world major)
announced Qualifying Points awards but silently omitted the "Knocked Out"
section. Knockouts are derived only on the primary window's bracket
(playoff_matches → newlyDecidedLoserIds); mirrors receive placement/rawScore
only, so a player eliminated in a non-scoring round (0 QP) becomes a
null-placement filler indistinguishable from "not yet played" — the mirror
has no local signal to detect the knockout, and its notification was also
gated on QP having changed.

Thread the primary's newly-eliminated participants down the fan-out
(syncTennisDraw → fanOutMajorIfPrimary → syncMajorFromPrimaryEvent →
syncTournamentResults → processQualifyingEvent), translating identity across
window boundaries (primary season_participant → canonical participant → each
mirror's season_participant), and relax the mirror notification guard to fire
on eliminations even when no QP changed — matching the primary path.

Reuses the same notifyQualifyingPointsUpdate the primary already calls, so
mirrors now produce the same combined "Points Awarded" + "Knocked Out" embed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WZGsG5R1q3kyKCaXuysiWJ
2026-07-04 16:24:36 +00:00

517 lines
21 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { eq, and, inArray } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import {
processQualifyingEvent,
recalculateAffectedLeagues,
buildTieCountByPlacement,
deriveBracketQualifyingStates,
getRoundConfig,
} from "~/models/scoring-calculator";
import { completeScoringEvent, getScoringEventById } from "~/models/scoring-event";
import { getEventResults } from "~/models/event-result";
import { upsertTournamentResult } from "~/models/tournament-result";
import { BRACKET_TEMPLATES } from "~/lib/bracket-templates";
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;
/**
* Pre-computed tie span (placement → tieCount) to split QP across each tied
* group, overriding the row-count derived from canonical tournament_results.
*
* For bracket majors (tennis, CS2) the tie span is a STRUCTURAL property of the
* round — R16 spans 8 slots (9th16th), QF 4, SF 2, Final 1 — not the live count
* of players currently sitting at a placement. Mid-tournament, players "floored"
* at a tier make the canonical row-count diverge from the structural span, so a
* mirror window would split differently than the primary (e.g. R16 losers at
* 2 QP instead of 1.5). syncMajorFromPrimaryEvent derives this map from the
* primary bracket so every window splits identically to the primary at every
* stage. Omitted for golf-style majors (no bracket) → canonical row-count is used.
*/
tieCountByPlacement?: Map<number, number>;
/**
* Canonical participant ids (participants.id) knocked out this sync in a
* non-scoring round on the primary bracket. Threaded through so each mirror
* window can translate them to its own season_participant ids and announce the
* "Knocked Out" section — these players earn no QP and so are otherwise invisible
* to the mirror (a null-placement filler row indistinguishable from "not yet
* played"). Canonical ids because they cross window boundaries; each window holds
* a different season_participant row for the same canonical participant.
*/
newlyEliminatedCanonicalParticipantIds?: Set<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,
skipNotifications = false,
tieCountByPlacement,
newlyEliminatedCanonicalParticipantIds,
} = 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 tie span (placement → count) used to split QP across a tied group. Default
// is the count of canonical rows at each placement (a whole-tournament property,
// computed once here). For bracket majors the caller also passes the STRUCTURAL
// span from the primary bracket (R16 = 8, QF = 4, …); merge it OVER the counts so
// bracket placements split like the primary even mid-tournament while any
// non-bracket placements (e.g. CS2 Swiss exits, golf) keep their canonical count.
const canonicalTieCountByPlacement = buildTieCountByPlacement(canonicalResults);
const effectiveTieCountByPlacement = tieCountByPlacement
? new Map([...canonicalTieCountByPlacement, ...tieCountByPlacement])
: canonicalTieCountByPlacement;
// 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. Translate the primary's newly-eliminated canonical participants into
// THIS window's season_participant ids (same participantId → sp.id key the
// canonical result copy uses above), so processQualifyingEvent can announce
// the "Knocked Out" section for players drafted in this window's leagues.
let windowEliminatedSpIds: Set<string> | undefined;
if (newlyEliminatedCanonicalParticipantIds?.size) {
windowEliminatedSpIds = new Set<string>();
for (const sp of rosters) {
if (sp.participantId && newlyEliminatedCanonicalParticipantIds.has(sp.participantId)) {
windowEliminatedSpIds.add(sp.id);
}
}
}
// 3e. Delegate to scoring engine inside the same transaction.
await processQualifyingEvent(ev.id, tx, {
skipNotifications,
canonicalTieCountByPlacement: effectiveTieCountByPlacement,
newlyEliminatedParticipantIds: windowEliminatedSpIds,
});
// 3f. 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;
/**
* Primary-window season_participant ids knocked out this sync in a non-scoring
* round (from the primary bracket's newlyDecidedLoserIds). Translated to
* canonical participant ids here, then fanned out so each mirror window can
* announce its own "Knocked Out" section.
*/
newlyEliminatedParticipantIds?: Set<string>;
} = {}
): Promise<SyncReport> {
const {
markComplete = false,
skipNotifications = false,
newlyEliminatedParticipantIds,
} = 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`
);
// For a bracket major, split each placement's QP by the round's STRUCTURAL tie
// span (the same span the primary used via processQualifyingBracketEvent), not by
// the live count of canonical rows at that placement. Mid-tournament, players
// floored at a tier inflate the row-count and would make mirror windows split
// differently than the primary (e.g. R16 losers → 2 QP instead of 1.5). Derived
// here so every fan-out caller (reprocess, live rounds, finalize) stays consistent.
const tieCountByPlacement = await deriveStructuralTieSpanForBracket(
primaryEvent,
db
);
// Translate the primary window's eliminated season_participant ids into canonical
// participant ids so the fan-out can re-key them per mirror window. These are
// non-scoring-round losers (null placement), so they were skipped from canonical
// promotion above — resolve them directly from season_participants, not from
// tournament_results.
let newlyEliminatedCanonicalParticipantIds: Set<string> | undefined;
if (newlyEliminatedParticipantIds?.size) {
const eliminatedRows = await db
.select({ participantId: schema.seasonParticipants.participantId })
.from(schema.seasonParticipants)
.where(
inArray(schema.seasonParticipants.id, [...newlyEliminatedParticipantIds])
);
newlyEliminatedCanonicalParticipantIds = new Set(
eliminatedRows
.map((r) => r.participantId)
.filter((id): id is string => id !== null)
);
}
return syncTournamentResults(tournamentId, {
markComplete,
skipEventId: primaryEventId,
skipNotifications,
tieCountByPlacement,
newlyEliminatedCanonicalParticipantIds,
});
}
/**
* Build a placement → structural tie-span map for a bracket major's primary
* window, mirroring the spans processQualifyingBracketEvent assigns (R16 losers
* span 8 slots, QF 4, SF 2, Final 1). Returns undefined for a primary with no
* bracket template or no matches (e.g. golf), so the fan-out falls back to counting
* canonical rows exactly as before.
*
* The span for a given placement is consistent across the bracket (every R16-tier
* state carries tieCount 8, whether the player is a final loser or still floored),
* so collapsing the per-participant states into a placement → tieCount map is safe.
*/
async function deriveStructuralTieSpanForBracket(
primaryEvent: { id: string; bracketTemplateId: string | null },
db: ReturnType<typeof database>
): Promise<Map<number, number> | undefined> {
if (!primaryEvent.bracketTemplateId) return undefined;
const template = BRACKET_TEMPLATES[primaryEvent.bracketTemplateId];
if (!template) return undefined;
const matches = await db
.select()
.from(schema.playoffMatches)
.where(eq(schema.playoffMatches.scoringEventId, primaryEvent.id));
if (matches.length === 0) return undefined;
const states = deriveBracketQualifyingStates(
matches.map((m) => ({
round: m.round,
winnerId: m.winnerId,
loserId: m.loserId,
participant1Id: m.participant1Id,
participant2Id: m.participant2Id,
})),
template.rounds,
(round) => getRoundConfig(round, primaryEvent.bracketTemplateId)
);
if (states.size === 0) return undefined;
const map = new Map<number, number>();
for (const { placement, tieCount } of states.values()) {
map.set(placement, tieCount);
}
return map;
}
/**
* 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;
/**
* Primary-window season_participant ids knocked out this sync in a non-scoring
* round. Forwarded to the fan-out so each mirror window announces its own
* "Knocked Out" section.
*/
newlyEliminatedParticipantIds?: Set<string>;
} = {}
): 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}`
);
}
}