brackt/app/services/match-sync/index.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

706 lines
27 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 { database } from "~/database/context";
import { eq } from "drizzle-orm";
import * as schema from "~/database/schema";
import {
findParticipantsBySportsSeasonId,
updateParticipant,
createParticipant,
createManyParticipants,
} from "~/models/season-participant";
import { upsertSeasonMatchBulk, upsertMatchSubGame } from "~/models/season-match";
import {
setMatchWinner,
updatePlayoffMatch,
advanceWinnerTemplate,
findPlayoffMatchesByEventId,
doesLoserAdvance,
populateBracketFromDraw,
} from "~/models/playoff-match";
import {
processMatchResult,
autoCompleteRoundIfDone,
processQualifyingBracketEvent,
recalculateAffectedLeagues,
} from "~/models/scoring-calculator";
import { fanOutMajorIfPrimary } from "~/services/sync-tournament-results";
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
import {
recalculateParticipantQP,
diffChangedQualifyingPoints,
} from "~/models/qualifying-points";
import { getScoringEventById, updateScoringEvent, isReadOnlySibling } from "~/models/scoring-event";
import { findMatchingTeamName, normalizeTeamName } from "~/lib/normalize-team-name";
import { buildUnmatchedTeamResolutionView } from "~/lib/unmatched-team-reconciliation";
import { getBracketTemplate } from "~/lib/bracket-templates";
import { PandaScoreMatchSyncAdapter } from "./pandascore";
import { EspnScheduleAdapter } from "./espn-schedule";
import { WikipediaTennisAdapter } from "./wikipedia-tennis";
import { playerKey, canonicalPlayerName, buildResolvedMatches } from "./tennis-draw-mapping";
import type {
MatchSyncAdapter,
MatchSyncResult,
DrawSyncAdapter,
DrawSyncResult,
DrawSyncPreview,
FetchedDraw,
FetchedPlayer,
} from "./types";
import { logger } from "~/lib/logger";
function getMatchSyncAdapter(simulatorType: string): MatchSyncAdapter {
switch (simulatorType) {
case "cs2_major_qualifying_points":
return new PandaScoreMatchSyncAdapter();
case "mlb_bracket":
return new EspnScheduleAdapter("baseball/mlb", 3);
case "nba_bracket":
return new EspnScheduleAdapter("basketball/nba", 3);
case "mls_bracket":
return new EspnScheduleAdapter("soccer/mls", 3);
case "wnba_bracket":
return new EspnScheduleAdapter("basketball/wnba", 3);
case "nhl_bracket":
return new EspnScheduleAdapter("hockey/nhl", 3);
default:
throw new Error(
`No match sync adapter available for simulator type "${simulatorType}". ` +
"CS2, MLB, NBA, MLS, WNBA, and NHL are currently supported."
);
}
}
export async function syncMatches(sportsSeasonId: string): Promise<MatchSyncResult> {
const db = database();
const sportsSeason = await db.query.sportsSeasons.findFirst({
where: eq(schema.sportsSeasons.id, sportsSeasonId),
with: { sport: true },
});
if (!sportsSeason) throw new Error(`Sports season ${sportsSeasonId} not found`);
const externalSeasonId = sportsSeason.externalSeasonId;
if (!externalSeasonId) {
throw new Error(`Sports season "${sportsSeason.name}" has no externalSeasonId configured`);
}
const simulatorType = sportsSeason.sport?.simulatorType;
if (!simulatorType) {
throw new Error(`Sport "${sportsSeason.sport?.name ?? "(none)"}" has no simulator type configured`);
}
const adapter = getMatchSyncAdapter(simulatorType);
const fetchedMatches = await adapter.fetchMatches(externalSeasonId);
// Load participants, build lookup maps
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
const participantNames = participants.map((p) => p.name);
const participantByExternalId = new Map(
participants.filter((p) => p.externalId).map((p) => [p.externalId as string, p])
);
const participantByName = new Map(participants.map((p) => [p.name, p]));
async function resolveParticipant(externalId: string, name: string) {
let participant = participantByExternalId.get(externalId) ?? null;
if (!participant) {
const matchedName = findMatchingTeamName(name, participantNames);
if (matchedName) {
participant = participantByName.get(matchedName) ?? null;
if (participant && !participant.externalId) {
await updateParticipant(participant.id, { externalId });
participantByExternalId.set(externalId, participant);
}
}
}
return participant;
}
// Find the scoring event for this sports season (needed for CS2 stage lookup + playoff sync)
const scoringEvents = await db.query.scoringEvents.findMany({
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
});
// Split matches: Swiss (matchStage set) vs playoff bracket (matchStage null)
const swissMatches = fetchedMatches.filter((m) => m.matchStage !== null && m.matchStage !== undefined);
const bracketMatches = fetchedMatches.filter((m) => m.matchStage === null || m.matchStage === undefined);
const unmatchedTeams: MatchSyncResult["unmatchedTeams"] = [];
const errors: MatchSyncResult["errors"] = [];
let swissCreated = 0;
let swissUpdated = 0;
let playoffUpdated = 0;
// --- Swiss matches → season_matches ---
if (swissMatches.length > 0) {
// For CS2, use the scoring event that has cs2_major_stage_results
const cs2Event = scoringEvents.find((e) => e.isQualifyingEvent) ?? scoringEvents[0] ?? null;
const toUpsert = [];
for (const m of swissMatches) {
const p1 = await resolveParticipant(m.team1ExternalId, m.team1Name);
const p2 = await resolveParticipant(m.team2ExternalId, m.team2Name);
if (!p1) unmatchedTeams.push({ externalId: m.team1ExternalId, name: m.team1Name });
if (!p2) unmatchedTeams.push({ externalId: m.team2ExternalId, name: m.team2Name });
const winnerParticipant = m.winnerExternalId
? (await resolveParticipant(m.winnerExternalId, ""))
: null;
toUpsert.push({
sportsSeasonId,
scoringEventId: cs2Event?.id ?? null,
participant1Id: p1?.id ?? null,
participant2Id: p2?.id ?? null,
winnerId: winnerParticipant?.id ?? null,
participant1Score: m.team1Score,
participant2Score: m.team2Score,
matchStage: m.matchStage ?? null,
matchRound: m.matchRound ?? null,
matchday: m.matchday ?? null,
isSeries: m.isSeries ?? false,
status: m.status,
scheduledAt: m.scheduledAt,
startedAt: m.startedAt,
completedAt: m.completedAt,
externalMatchId: m.externalMatchId,
});
}
const upserted = await upsertSeasonMatchBulk(toUpsert);
// Track created vs updated (rough heuristic: if completedAt is within last 2 min, it's new)
swissCreated = upserted.filter((r) => {
const diff = r.createdAt ? Date.now() - r.createdAt.getTime() : Infinity;
return diff < 120_000;
}).length;
swissUpdated = upserted.length - swissCreated;
// Upsert sub-games (maps) for matches with subGames
for (const m of swissMatches) {
if (!m.subGames?.length) continue;
const upsertedMatch = upserted.find((u) => u.externalMatchId === m.externalMatchId);
if (!upsertedMatch) continue;
for (const g of m.subGames) {
const winnerParticipant = g.winnerExternalId
? (await resolveParticipant(g.winnerExternalId, ""))
: null;
try {
await upsertMatchSubGame({
seasonMatchId: upsertedMatch.id,
gameNumber: g.gameNumber,
gameLabel: g.gameLabel ?? null,
participant1Score: g.team1Score,
participant2Score: g.team2Score,
winnerId: winnerParticipant?.id ?? null,
status: g.status,
externalGameId: g.externalGameId ?? null,
});
} catch (err) {
logger.error(`[match-sync] Error upserting sub-game ${g.gameNumber} for match ${m.externalMatchId}:`, err);
}
}
}
}
// --- Bracket matches → playoff_matches ---
if (bracketMatches.length > 0 && scoringEvents.length === 0) {
logger.warn(`[match-sync] ${bracketMatches.length} bracket match(es) found but no scoring events exist for season ${sportsSeasonId} — bracket sync skipped`);
}
if (bracketMatches.length > 0) {
for (const event of scoringEvents) {
const existingPlayoffMatches = await findPlayoffMatchesByEventId(event.id);
if (existingPlayoffMatches.length === 0) continue;
const bracketTemplate = event.bracketTemplateId
? getBracketTemplate(event.bracketTemplateId)
: null;
for (const m of bracketMatches) {
if (m.status !== "complete") continue;
const p1 = await resolveParticipant(m.team1ExternalId, m.team1Name);
const p2 = await resolveParticipant(m.team2ExternalId, m.team2Name);
if (!p1 || !p2) continue;
const winnerParticipant = m.winnerExternalId
? await resolveParticipant(m.winnerExternalId, "")
: null;
if (!winnerParticipant) continue;
const loserId = winnerParticipant.id === p1.id ? p2.id : p1.id;
// Find the matching playoff_matches row: prefer external_match_id match, fall back to participant ID match
let playoffMatch = existingPlayoffMatches.find(
(pm) => pm.externalMatchId === m.externalMatchId
);
if (!playoffMatch) {
playoffMatch = existingPlayoffMatches.find(
(pm) =>
!pm.isComplete &&
((pm.participant1Id === p1.id && pm.participant2Id === p2.id) ||
(pm.participant1Id === p2.id && pm.participant2Id === p1.id))
);
}
if (!playoffMatch) continue;
if (playoffMatch.isComplete && playoffMatch.winnerId) continue; // idempotent
try {
await setMatchWinner(
playoffMatch.id,
winnerParticipant.id,
loserId,
winnerParticipant.id === p1.id ? (m.team1Score ?? undefined) : (m.team2Score ?? undefined),
winnerParticipant.id === p1.id ? (m.team2Score ?? undefined) : (m.team1Score ?? undefined)
);
// Set external_match_id for future upserts
if (!playoffMatch.externalMatchId) {
await updatePlayoffMatch(playoffMatch.id, { externalMatchId: m.externalMatchId });
}
if (bracketTemplate) {
try {
await advanceWinnerTemplate(playoffMatch.id, winnerParticipant.id, bracketTemplate);
} catch (err) {
if (!(err instanceof Error && err.message.includes("already filled"))) throw err;
}
}
const roundIsScoring = bracketTemplate?.rounds.find(
(r) => r.name === playoffMatch.round
)?.isScoring;
await processMatchResult({
round: playoffMatch.round,
winnerId: winnerParticipant.id,
loserId,
isScoring: roundIsScoring !== undefined ? roundIsScoring : (playoffMatch.isScoring ?? true),
sportsSeasonId,
bracketTemplateId: event.bracketTemplateId ?? null,
eventId: event.id,
eventName: event.name ?? undefined,
matchId: playoffMatch.id,
loserAdvances: event.bracketTemplateId
? doesLoserAdvance(playoffMatch.round, playoffMatch.matchNumber, event.bracketTemplateId)
: false,
});
await autoCompleteRoundIfDone(event.id, playoffMatch.round, sportsSeasonId, db);
playoffUpdated++;
} catch (err) {
logger.error(`[match-sync] Error processing playoff match ${m.externalMatchId}:`, err);
errors.push({
externalMatchId: m.externalMatchId,
error: err instanceof Error ? err.message : String(err),
});
}
}
}
}
return { swissCreated, swissUpdated, playoffUpdated, unmatchedTeams, errors };
}
// ===========================================================================
// Tennis Grand Slam draw sync
// ===========================================================================
function getDrawAdapter(simulatorType: string): DrawSyncAdapter {
switch (simulatorType) {
case "tennis_qualifying_points":
return new WikipediaTennisAdapter();
default:
throw new Error(
`No draw sync adapter available for simulator type "${simulatorType}". ` +
"Tennis is currently supported.",
);
}
}
/** Validate the event, fetch + parse its draw. Read-only — used by sync & preview. */
async function loadTennisDraw(eventId: string) {
const db = database();
const event = await getScoringEventById(eventId);
if (!event) throw new Error(`Scoring event ${eventId} not found`);
if (isReadOnlySibling(event)) {
throw new Error(
"This event is a read-only window of a shared major. Sync the draw on the primary window; " +
"results fan out here automatically.",
);
}
if (!event.isQualifyingEvent) {
throw new Error(`Event "${event.name}" is not a qualifying event; cannot sync a tennis draw.`);
}
const sourceKey = event.externalSourceKey;
if (!sourceKey) {
throw new Error(
`Event "${event.name}" has no externalSourceKey (Wikipedia article title) configured.`,
);
}
const sportsSeason = await db.query.sportsSeasons.findFirst({
where: eq(schema.sportsSeasons.id, event.sportsSeasonId),
with: { sport: true },
});
const simulatorType = sportsSeason?.sport?.simulatorType;
if (!simulatorType) {
throw new Error(`Sport for season ${event.sportsSeasonId} has no simulator type configured`);
}
const draw = await getDrawAdapter(simulatorType).fetchDraw(sourceKey);
const template = getBracketTemplate(draw.templateId);
if (!template) throw new Error(`Bracket template "${draw.templateId}" not found`);
return { event, sportsSeasonId: event.sportsSeasonId, draw, template };
}
/** Distinct players across the whole draw, keyed by stable identity. */
function collectUniquePlayers(draw: FetchedDraw): Map<string, FetchedPlayer> {
const players = new Map<string, FetchedPlayer>();
for (const round of draw.rounds) {
for (const match of round.matches) {
for (const player of [match.player1, match.player2]) {
if (player) players.set(playerKey(player), player);
}
}
}
return players;
}
type SeasonParticipant = Awaited<ReturnType<typeof findParticipantsBySportsSeasonId>>[number];
type PlayerLookups = {
byExternalId: Map<string, SeasonParticipant>;
byName: Map<string, SeasonParticipant>;
names: string[];
recon: { id: string; name: string; externalId: string | null }[];
};
function buildLookups(participants: SeasonParticipant[]): PlayerLookups {
return {
byExternalId: new Map(
participants.filter((p) => p.externalId).map((p) => [p.externalId as string, p]),
),
byName: new Map(participants.map((p) => [p.name, p])),
names: participants.map((p) => p.name),
recon: participants.map((p) => ({ id: p.id, name: p.name, externalId: p.externalId })),
};
}
type Classification =
| { kind: "matched"; participant: SeasonParticipant; backfill: boolean }
| {
kind: "create";
name: string;
suggestion: string | null;
suggestionId: string | null;
};
/** Read-only: decide how a drawn player resolves against existing participants. */
function classifyPlayer(player: FetchedPlayer, lk: PlayerLookups): Classification {
const name = canonicalPlayerName(player);
const byId = player.externalId ? lk.byExternalId.get(player.externalId) : undefined;
if (byId) return { kind: "matched", participant: byId, backfill: false };
const matchedName = findMatchingTeamName(name, lk.names);
if (matchedName) {
const p = lk.byName.get(matchedName);
if (p) return { kind: "matched", participant: p, backfill: !p.externalId && !!player.externalId };
}
// No confident match. Surface the closest existing participant (if any) as a
// possible duplicate to review.
const view = buildUnmatchedTeamResolutionView(name, lk.recon);
const top = view.confidence !== "none" ? view.topCandidates[0] ?? null : null;
return {
kind: "create",
name,
suggestion: top?.participantName ?? null,
suggestionId: top?.participantId ?? null,
};
}
/**
* Dry run: report what a draw sync would do (matches, would-be participant
* creations, likely duplicates, unfilled first-round slots) without any writes.
*/
export async function previewTennisDraw(eventId: string): Promise<DrawSyncPreview> {
const { event, sportsSeasonId, draw } = await loadTennisDraw(eventId);
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
const lk = buildLookups(participants);
const uniquePlayers = collectUniquePlayers(draw);
let matched = 0;
const willCreate: DrawSyncPreview["willCreate"] = [];
const possibleDuplicates: DrawSyncPreview["possibleDuplicates"] = [];
for (const player of uniquePlayers.values()) {
const c = classifyPlayer(player, lk);
if (c.kind === "matched") {
matched++;
} else {
willCreate.push({ name: c.name, externalId: player.externalId });
if (c.suggestion && c.suggestionId) {
possibleDuplicates.push({
name: c.name,
externalId: player.externalId,
suggestion: c.suggestion,
suggestionId: c.suggestionId,
});
}
}
}
const firstRound = draw.rounds[0];
const tbdFirstRound = firstRound
? firstRound.matches.reduce((n, m) => n + (m.player1 ? 0 : 1) + (m.player2 ? 0 : 1), 0)
: 0;
const allMatches = draw.rounds.flatMap((r) => r.matches);
return {
article: event.externalSourceKey ?? "",
totalPlayers: uniquePlayers.size,
matched,
willCreate,
possibleDuplicates,
tbdFirstRound,
totalMatches: allMatches.length,
completedMatches: allMatches.filter((m) => m.winner !== null).length,
};
}
/**
* Populate and auto-score a tennis major's bracket from its external draw
* (Wikipedia). Resolves every drawn player to a participant — matching by
* external id, then name, else auto-creating — propagates new participants to
* every sports season linked to the same tournament (so result fan-out lands),
* writes the full bracket, and runs the qualifying-points scorer.
*
* Runs only on a major's primary window; read-only siblings receive results via
* fan-out, not direct scoring.
*/
export async function syncTennisDraw(eventId: string): Promise<DrawSyncResult> {
const db = database();
const { event, sportsSeasonId, draw, template } = await loadTennisDraw(eventId);
const roundIsScoring = new Map(template.rounds.map((r) => [r.name, r.isScoring]));
// ---- Resolve players → participants (primary season) ----------------------
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
const lk = buildLookups(participants);
const uniquePlayers = collectUniquePlayers(draw);
const keyToParticipantId = new Map<string, string>();
const resolvedParticipants = new Map<
string,
{ canonicalId: string | null; name: string; externalId: string | null }
>();
const unmatched: DrawSyncResult["unmatched"] = [];
let participantsCreated = 0;
for (const [key, player] of uniquePlayers) {
const c = classifyPlayer(player, lk);
let participant: SeasonParticipant;
if (c.kind === "matched") {
participant = c.participant;
if (c.backfill && player.externalId) {
await updateParticipant(participant.id, { externalId: player.externalId });
}
} else {
// No confident match → auto-create so the bracket is complete; flag a
// likely duplicate for admin review when it resembles an existing player.
if (c.suggestion) unmatched.push({ name: c.name, externalId: player.externalId });
participant = await createParticipant({
sportsSeasonId,
name: c.name,
externalId: player.externalId ?? null,
});
lk.names.push(participant.name);
lk.byName.set(participant.name, participant);
lk.recon.push({ id: participant.id, name: participant.name, externalId: participant.externalId });
if (participant.externalId) lk.byExternalId.set(participant.externalId, participant);
participantsCreated++;
}
keyToParticipantId.set(key, participant.id);
resolvedParticipants.set(key, {
canonicalId: participant.participantId ?? null,
name: participant.name,
externalId: participant.externalId ?? null,
});
}
// ---- Propagate participants to linked sibling seasons ---------------------
if (event.tournamentId) {
const linkedRows = await db
.select({ sportsSeasonId: schema.scoringEvents.sportsSeasonId })
.from(schema.scoringEvents)
.where(eq(schema.scoringEvents.tournamentId, event.tournamentId));
const linkedSeasonIds = [...new Set(linkedRows.map((r) => r.sportsSeasonId))].filter(
(id) => id !== sportsSeasonId,
);
// Dedupe by canonical participant: two drawn players can fuzzy-match the same
// existing participant, which would otherwise queue duplicate sibling inserts
// and trip the (sportsSeasonId, name) unique index.
const resolved = [
...new Map(
[...resolvedParticipants.values()]
.filter((r) => r.canonicalId)
.map((r) => [r.canonicalId, r]),
).values(),
];
for (const seasonId of linkedSeasonIds) {
const sib = await findParticipantsBySportsSeasonId(seasonId);
const sibCanon = new Set(sib.map((s) => s.participantId).filter(Boolean));
const sibNames = new Set(sib.map((s) => normalizeTeamName(s.name)));
const toCreate = resolved
.filter(
(r) =>
r.canonicalId &&
!sibCanon.has(r.canonicalId) &&
!sibNames.has(normalizeTeamName(r.name)),
)
.map((r) => ({
sportsSeasonId: seasonId,
name: r.name,
externalId: r.externalId,
participantId: r.canonicalId,
}));
if (toCreate.length > 0) {
await createManyParticipants(toCreate);
participantsCreated += toCreate.length;
}
}
}
// ---- Write the bracket ----------------------------------------------------
const resolvedMatches = buildResolvedMatches(draw, keyToParticipantId, roundIsScoring);
// Ensure the event is configured for QP bracket scoring before populating.
if (
event.bracketTemplateId !== draw.templateId ||
event.scoringStartsAtRound !== template.scoringStartsAtRound
) {
await updateScoringEvent(eventId, {
bracketTemplateId: draw.templateId,
scoringStartsAtRound: template.scoringStartsAtRound,
});
}
const { written, completed, newlyDecidedLoserIds } = await populateBracketFromDraw(
eventId,
resolvedMatches,
);
// ---- Score (qualifying points) + fan out to siblings ----------------------
// Re-derive QP from the bracket and learn which participants' QP changed so the
// Discord notification below only announces new/changed results on a re-sync.
const changedParticipantIds = await rescoreTennisBracketAndDetectChanges(
eventId,
sportsSeasonId,
db,
);
await recalculateAffectedLeagues(sportsSeasonId, db, {
eventId,
eventName: event.name ?? undefined,
});
// Players knocked out this sync in a non-scoring round (rounds 13 of a Grand
// Slam) earn no QP and so never appear via changedParticipantIds. Computed here
// so it feeds BOTH the primary's own notification below AND the fan-out, which
// propagates it to every mirror window (whose placement-only data can't detect a
// knockout on its own).
const newlyEliminatedIds = new Set(newlyDecidedLoserIds);
await fanOutMajorIfPrimary(
{ id: event.id, isPrimary: event.isPrimary, tournamentId: event.tournamentId },
{ markComplete: false, newlyEliminatedParticipantIds: newlyEliminatedIds },
);
// Announce QP changes for the primary window. Sibling windows are announced by
// the fan-out (via processQualifyingEvent); the primary is scored directly by
// processQualifyingBracketEvent, which does NOT notify — so do it here. Run
// outside the rescore transaction so the webhook HTTP call neither holds the
// transaction open nor rolls back the score if Discord fails.
//
// Fire even when no QP changed, so an early-round elimination is still surfaced.
if (changedParticipantIds.size > 0 || newlyEliminatedIds.size > 0) {
try {
await notifyQualifyingPointsUpdate(
sportsSeasonId,
eventId,
db,
changedParticipantIds,
newlyEliminatedIds,
);
} catch (error) {
logger.error(`[syncTennisDraw] QP Discord notification failed for event ${eventId}:`, error);
}
}
return { matchesWritten: written, completed, participantsCreated, unmatched };
}
/**
* Re-derive a tennis major's qualifying points from its bracket and report which
* season participants' awarded QP changed since the previous scoring.
*
* The synced draw is authoritative for tennis, so this clears ALL event_results
* for the event (including any manually-entered rows, e.g. a notParticipating
* withdrawal) and rebuilds them from the bracket. Change detection therefore
* snapshots each participant's awarded QP *before* the delete and diffs it against
* the freshly written rows: a value that differs, or a participant scored for the
* first time (null → value), counts as changed. Wrapped in a transaction so a
* mid-rescore failure can't leave the event with results deleted-but-not-rebuilt.
*
* writeEventResultsQP (inside processQualifyingBracketEvent) upserts but never
* deletes, so QP totals for participants who no longer earn points (e.g. an
* early-round loser dropped from the rewritten results — only Round of 16+ losers
* score) are recalculated by hand.
*
* Returns the set of season_participant ids whose QP changed, used to scope the
* QP Discord notification so a re-sync that changes nothing announces nothing.
*/
export async function rescoreTennisBracketAndDetectChanges(
eventId: string,
sportsSeasonId: string,
db: ReturnType<typeof database>,
): Promise<Set<string>> {
let changed = new Set<string>();
await db.transaction(async (tx) => {
const beforeRows = await tx
.select({
id: schema.eventResults.seasonParticipantId,
qp: schema.eventResults.qualifyingPointsAwarded,
})
.from(schema.eventResults)
.where(eq(schema.eventResults.scoringEventId, eventId));
await tx.delete(schema.eventResults).where(eq(schema.eventResults.scoringEventId, eventId));
await processQualifyingBracketEvent(eventId, tx);
const afterRows = await tx
.select({
id: schema.eventResults.seasonParticipantId,
qp: schema.eventResults.qualifyingPointsAwarded,
})
.from(schema.eventResults)
.where(eq(schema.eventResults.scoringEventId, eventId));
const afterIds = new Set(afterRows.map((r) => r.id));
for (const { id } of beforeRows) {
if (!afterIds.has(id)) await recalculateParticipantQP(id, sportsSeasonId, tx);
}
changed = diffChangedQualifyingPoints(beforeRows, afterRows);
});
return changed;
}