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:
parent
c60544936f
commit
c47cbe7d7a
2 changed files with 137 additions and 4 deletions
106
app/models/__tests__/scoring-calculator-qp-notify.test.ts
Normal file
106
app/models/__tests__/scoring-calculator-qp-notify.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue