Only notify changed participants from processQualifyingEvent

processQualifyingEvent announced the full QP standings on every run. In a
multi-season shared major (e.g. Wimbledon), syncTournamentResults re-scores
each sibling window on every fan-out sync, so sibling-window leagues received
a duplicate full-standings ping each run even when nothing changed — the same
re-spam the primary tennis path already avoids.

Snapshot each participant's awarded QP before reprocessing and, after the
re-score, diff against the fresh rows to notify only participants whose QP
changed (a differing value, or a first-time score). This centralizes the
change-detection in the shared scoring path, so every sibling window and the
admin scoring/bracket routes get the quiet-on-no-change behavior.

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:54:51 +00:00
parent c60544936f
commit c47cbe7d7a
No known key found for this signature in database
2 changed files with 137 additions and 4 deletions

View file

@ -0,0 +1,106 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Spy on the QP Discord notification, and no-op the per-participant total recalc
// (its own eventResults query would otherwise interfere with the before/after
// snapshot counter below). Everything else in the scorer runs for real.
vi.mock("~/services/qualifying-points-discord.server", () => ({
notifyQualifyingPointsUpdate: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("~/models/qualifying-points", async (importActual) => {
const actual = await importActual<typeof import("~/models/qualifying-points")>();
return {
...actual,
recalculateParticipantQP: vi.fn().mockResolvedValue({ totalQP: 0, eventsScored: 0 }),
};
});
import { processQualifyingEvent } from "../scoring-calculator";
import { DEFAULT_QP_VALUES } from "../qualifying-points";
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
const EVENT_ID = "event-1";
const SPORTS_SEASON_ID = "sports-season-1";
type ResultRow = {
id: string;
seasonParticipantId: string;
placement: number | null;
qualifyingPointsAwarded: string | null;
scoringEvent: { id: string; sportsSeasonId: string };
};
/**
* Mock db for the non-bracket `processQualifyingEvent` path. `eventResults.findMany`
* returns `before` on its first call (the initial getEventResults snapshot) and
* `after` on its second (the post-reprocess re-query that drives change detection).
* Awarding updates go to a no-op update chain `after` stands in for the DB state
* once the scorer has written its QP.
*/
function makeDb(before: ResultRow[], after: ResultRow[]) {
const results = [before, after];
let findManyCall = 0;
return {
query: {
scoringEvents: {
findFirst: async () => ({
id: EVENT_ID,
sportsSeasonId: SPORTS_SEASON_ID,
isQualifyingEvent: true,
bracketTemplateId: null,
sportsSeason: { majorsCompleted: 1 },
}),
},
eventResults: {
findMany: async () => results[Math.min(findManyCall++, 1)],
},
qualifyingPointConfig: {
findMany: async () =>
DEFAULT_QP_VALUES.map((qp) => ({ placement: qp.placement, points: qp.points.toString() })),
},
},
update: () => ({ set: () => ({ where: async () => [] }) }),
} as never;
}
const scoringEvent = { id: EVENT_ID, sportsSeasonId: SPORTS_SEASON_ID };
beforeEach(() => {
vi.clearAllMocks();
});
describe("processQualifyingEvent — QP change notification", () => {
it("notifies only participants whose QP changed (first-time score: null → value)", async () => {
const db = makeDb(
[
{ id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: null, scoringEvent },
{ id: "r2", seasonParticipantId: "p2", placement: 20, qualifyingPointsAwarded: "0.00", scoringEvent },
],
[
{ id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: "100.00", scoringEvent },
{ id: "r2", seasonParticipantId: "p2", placement: 20, qualifyingPointsAwarded: "0.00", scoringEvent },
],
);
await processQualifyingEvent(EVENT_ID, db);
expect(notifyQualifyingPointsUpdate).toHaveBeenCalledTimes(1);
const [ssId, evId, , filter] = vi.mocked(notifyQualifyingPointsUpdate).mock.calls[0];
expect(ssId).toBe(SPORTS_SEASON_ID);
expect(evId).toBe(EVENT_ID);
// p1 went null → 100 (changed); p2 stayed at 0 (unchanged).
expect([...(filter as Set<string>)]).toEqual(["p1"]);
});
it("does not notify when a re-sync produces identical QP", async () => {
const rows: ResultRow[] = [
{ id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: "100.00", scoringEvent },
{ id: "r2", seasonParticipantId: "p2", placement: 20, qualifyingPointsAwarded: "0.00", scoringEvent },
];
const db = makeDb(rows, rows.map((r) => ({ ...r })));
await processQualifyingEvent(EVENT_ID, db);
expect(notifyQualifyingPointsUpdate).not.toHaveBeenCalled();
});
});

View file

@ -879,6 +879,16 @@ export async function processQualifyingEvent(
// Check if this was already processed (for majorsCompleted counter)
const wasAlreadyProcessed = hasProcessedQualifyingPlacement(results);
// Snapshot awarded QP before reprocessing so the Discord notification below can
// announce only the participants whose QP actually changed. Without this, a
// sibling window re-scored on every fan-out sync (syncTournamentResults) would
// re-ping the full QP standings each run even when nothing changed.
const beforeQP = new Map<string, number>(
results
.filter((r) => r.qualifyingPointsAwarded !== null)
.map((r) => [r.seasonParticipantId, parseFloat(r.qualifyingPointsAwarded as string)])
);
if (event.bracketTemplateId) {
// Bracket-based qualifying event (e.g. CS2 Champions Stage): QP is owned by the
// bracket/stage writers, which assign each placement its STRUCTURAL tie span.
@ -960,10 +970,27 @@ export async function processQualifyingEvent(
`[ScoringCalculator] Processed qualifying event ${eventId}: awarded QP to ${results.length} participants`
);
try {
await notifyQualifyingPointsUpdate(event.sportsSeasonId, eventId, db);
} catch (error) {
logger.error(`[ScoringCalculator] QP Discord notification failed for event ${eventId}:`, error);
// Announce only participants whose awarded QP changed vs. the pre-reprocess
// snapshot (a differing value, or a first-time score: null → value). This keeps
// repeated fan-out syncs of an unchanged window from re-pinging the standings.
const afterRows = await db.query.eventResults.findMany({
where: eq(schema.eventResults.scoringEventId, eventId),
});
const changedParticipantIds = new Set<string>();
for (const r of afterRows) {
if (r.qualifyingPointsAwarded === null) continue;
const newQP = parseFloat(r.qualifyingPointsAwarded);
if (beforeQP.get(r.seasonParticipantId) !== newQP) {
changedParticipantIds.add(r.seasonParticipantId);
}
}
if (changedParticipantIds.size > 0) {
try {
await notifyQualifyingPointsUpdate(event.sportsSeasonId, eventId, db, changedParticipantIds);
} catch (error) {
logger.error(`[ScoringCalculator] QP Discord notification failed for event ${eventId}:`, error);
}
}
}