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
517 lines
21 KiB
TypeScript
517 lines
21 KiB
TypeScript
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 (9th–16th), 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}`
|
||
);
|
||
}
|
||
}
|