Send QP Discord notification from tennis (Wikipedia) sync

The tennis Grand Slam draw sync (`syncTennisDraw`) scores its primary
window by calling `processQualifyingBracketEvent` directly, which writes
QP but never emits the qualifying-points Discord notification (only
`processQualifyingEvent` and the CS2 stage scorer did). As a result,
running the Wimbledon sync produced no QP notifications for the season
that leagues actually draft from.

Extract the rescore into `rescoreTennisBracketAndDetectChanges`, which
snapshots each participant's awarded QP before rebuilding results and
reports whose QP changed. `syncTennisDraw` then calls
`notifyQualifyingPointsUpdate` for the primary window, scoped to the
changed participants so a re-run that changes nothing announces nothing.
The webhook call runs outside the rescore transaction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hq3fG4Zn3unntbSQAGSqCq
This commit is contained in:
Claude 2026-07-01 20:32:11 +00:00
parent b0c25beb90
commit c60544936f
No known key found for this signature in database
2 changed files with 201 additions and 29 deletions

View file

@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// The helper under test re-derives QP via processQualifyingBracketEvent and
// recalculates dropped participants — both stubbed so the test drives the
// before/after event_results rows directly and asserts the change-detection diff.
vi.mock("~/models/scoring-calculator", () => ({
processMatchResult: vi.fn(),
autoCompleteRoundIfDone: vi.fn(),
processQualifyingBracketEvent: vi.fn().mockResolvedValue(undefined),
recalculateAffectedLeagues: vi.fn(),
}));
vi.mock("~/models/qualifying-points", () => ({
recalculateParticipantQP: vi.fn().mockResolvedValue(undefined),
}));
import { rescoreTennisBracketAndDetectChanges } from "../index";
import { processQualifyingBracketEvent } from "~/models/scoring-calculator";
import { recalculateParticipantQP } from "~/models/qualifying-points";
const EVENT_ID = "ev-1";
const SPORTS_SEASON_ID = "ss-1";
type Row = { pid: string; qp: string | null };
/**
* Fake Drizzle db whose two `select().from().where()` calls resolve, in order, to
* the provided before-rows then after-rows. `transaction` runs its callback with
* the same fake, and `delete().where()` is a no-op.
*/
function makeDb(beforeRows: Row[], afterRows: Row[]) {
const results = [beforeRows, afterRows];
let selectCall = 0;
const db = {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => Promise.resolve(results[selectCall++] ?? [])),
})),
})),
delete: vi.fn(() => ({ where: vi.fn(() => Promise.resolve(undefined)) })),
transaction: vi.fn(async (fn: (tx: unknown) => Promise<void>) => fn(db)),
};
return db;
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("rescoreTennisBracketAndDetectChanges", () => {
it("flags participants whose QP changed and newly-scored participants, ignoring unchanged ones", async () => {
const db = makeDb(
[
{ pid: "A", qp: "10" },
{ pid: "B", qp: "5" },
],
[
{ pid: "A", qp: "10" }, // unchanged
{ pid: "B", qp: "8" }, // changed 5 -> 8
{ pid: "C", qp: "3" }, // newly scored
],
);
const changed = await rescoreTennisBracketAndDetectChanges(EVENT_ID, SPORTS_SEASON_ID, db as never);
expect([...changed].sort()).toEqual(["B", "C"]);
expect(processQualifyingBracketEvent).toHaveBeenCalledWith(EVENT_ID, db);
// No participant dropped out, so no manual total recalc.
expect(recalculateParticipantQP).not.toHaveBeenCalled();
});
it("treats a null-before → value-after transition as a change", async () => {
const db = makeDb([{ pid: "A", qp: null }], [{ pid: "A", qp: "10" }]);
const changed = await rescoreTennisBracketAndDetectChanges(EVENT_ID, SPORTS_SEASON_ID, db as never);
expect([...changed]).toEqual(["A"]);
});
it("announces nothing when a re-sync produces identical QP", async () => {
const db = makeDb(
[
{ pid: "A", qp: "10" },
{ pid: "B", qp: "5" },
],
[
{ pid: "A", qp: "10" },
{ pid: "B", qp: "5" },
],
);
const changed = await rescoreTennisBracketAndDetectChanges(EVENT_ID, SPORTS_SEASON_ID, db as never);
expect(changed.size).toBe(0);
expect(recalculateParticipantQP).not.toHaveBeenCalled();
});
it("recalculates totals for participants dropped from the rewritten results", async () => {
const db = makeDb(
[
{ pid: "A", qp: "10" },
{ pid: "D", qp: "2" }, // present before, gone after (early-round loser)
],
[{ pid: "A", qp: "10" }],
);
const changed = await rescoreTennisBracketAndDetectChanges(EVENT_ID, SPORTS_SEASON_ID, db as never);
expect(changed.size).toBe(0); // A unchanged; D no longer in results
expect(recalculateParticipantQP).toHaveBeenCalledTimes(1);
expect(recalculateParticipantQP).toHaveBeenCalledWith("D", SPORTS_SEASON_ID, db);
});
});

View file

@ -23,6 +23,7 @@ import {
recalculateAffectedLeagues,
} from "~/models/scoring-calculator";
import { fanOutMajorIfPrimary } from "~/services/sync-tournament-results";
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
import { recalculateParticipantQP } from "~/models/qualifying-points";
import { getScoringEventById, updateScoringEvent, isReadOnlySibling } from "~/models/scoring-event";
import { findMatchingTeamName, normalizeTeamName } from "~/lib/normalize-team-name";
@ -592,35 +593,13 @@ export async function syncTennisDraw(eventId: string): Promise<DrawSyncResult> {
const { written, completed } = await populateBracketFromDraw(eventId, resolvedMatches);
// ---- Score (qualifying points) + fan out to siblings ----------------------
// The tennis bracket is the sole QP source for its event, so reconcile stale
// rows: snapshot existing result rows, clear them, re-derive from the bracket,
// then recalc QP totals for any participant who no longer earns points (e.g.
// early-round losers — only Round of 16+ losers score). writeEventResultsQP
// upserts but never deletes, so without this a previously mis-scored player
// would keep phantom QP across re-syncs.
//
// Wrapped in a transaction so a mid-rescore failure can't leave the event with
// its results deleted and not rebuilt. NOTE: this clears ALL event_results for
// the event, including any manually-entered rows (e.g. a notParticipating
// withdrawal) — acceptable because the synced draw is authoritative for tennis.
await db.transaction(async (tx) => {
const beforeRows = await tx
.select({ pid: schema.eventResults.seasonParticipantId })
.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({ pid: schema.eventResults.seasonParticipantId })
.from(schema.eventResults)
.where(eq(schema.eventResults.scoringEventId, eventId));
const afterIds = new Set(afterRows.map((r) => r.pid));
for (const { pid } of beforeRows) {
if (!afterIds.has(pid)) await recalculateParticipantQP(pid, sportsSeasonId, tx);
}
});
// 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,
@ -631,5 +610,85 @@ export async function syncTennisDraw(eventId: string): Promise<DrawSyncResult> {
{ markComplete: false },
);
// 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.
if (changedParticipantIds.size > 0) {
try {
await notifyQualifyingPointsUpdate(sportsSeasonId, eventId, db, changedParticipantIds);
} 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>> {
const changed = new Set<string>();
await db.transaction(async (tx) => {
const beforeRows = await tx
.select({
pid: schema.eventResults.seasonParticipantId,
qp: schema.eventResults.qualifyingPointsAwarded,
})
.from(schema.eventResults)
.where(eq(schema.eventResults.scoringEventId, eventId));
const beforeQP = new Map<string, number>(
beforeRows
.filter((r) => r.qp !== null)
.map((r) => [r.pid, parseFloat(r.qp as string)]),
);
await tx.delete(schema.eventResults).where(eq(schema.eventResults.scoringEventId, eventId));
await processQualifyingBracketEvent(eventId, tx);
const afterRows = await tx
.select({
pid: schema.eventResults.seasonParticipantId,
qp: schema.eventResults.qualifyingPointsAwarded,
})
.from(schema.eventResults)
.where(eq(schema.eventResults.scoringEventId, eventId));
const afterIds = new Set(afterRows.map((r) => r.pid));
for (const { pid } of beforeRows) {
if (!afterIds.has(pid)) await recalculateParticipantQP(pid, sportsSeasonId, tx);
}
for (const r of afterRows) {
if (r.qp === null) continue;
const newQP = parseFloat(r.qp as string);
if (beforeQP.get(r.pid) !== newQP) changed.add(r.pid);
}
});
return changed;
}