Four related bugs in the Qualifying Points subsystem surfaced on Wimbledon: 1. Some leagues stored 2 QP instead of 1.5 for Round-of-16 losers. Sibling/ mirror windows scored via the placement-group path in processQualifyingEvent, which took the tie span from the players present on that window's roster (a draftable subset) instead of the full field. A window holding fewer than the 8 tied R16 losers split the 9-16 points too few ways and over-awarded. The tie span is now derived from the canonical tournament_results (the whole field) for tournament-linked events, falling back to the live count only for standalone events. Every league now scores identically. 2. Discord notifications rounded QP with Math.round, turning 1.5 into "2". Both the "Points Awarded" and "QP Standings" values now use a 2-decimal formatter mirroring the web UI's formatQP, so Discord and the site agree. 3. The Discord "QP Standings" block ranked players only among that event's scorers, showing two R16 losers as T1 instead of T9. Rank is now computed over the full season field and passed through to the notifier. 4. "N of M majors completed" reached "11 of 4": majorsCompleted was a stored counter incremented on every fan-out sync with a guard that misfired. It is now derived on read (getMajorsCompleted = count of completed qualifying events), which is self-correcting; the increment/decrement writes are removed along with the now-unused hasProcessedQualifyingPlacement helper. Adds scripts/backfill-qp-resplit.ts to silently re-score existing majors so already-corrupted seasons are corrected (2 -> 1.5), and threads a skipNotifications option through processQualifyingEvent / syncTournamentResults so the backfill does not re-ping every league. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017AUcHy9m7aF6axsY6Grpe4
352 lines
13 KiB
TypeScript
352 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;
|
|
/**
|
|
* 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));
|
|
|
|
// 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 });
|
|
|
|
// 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. 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}`
|
|
);
|
|
}
|
|
}
|