diff --git a/app/models/__tests__/scoring-calculator-qp-notify.test.ts b/app/models/__tests__/scoring-calculator-qp-notify.test.ts index b5470d5..9547fb5 100644 --- a/app/models/__tests__/scoring-calculator-qp-notify.test.ts +++ b/app/models/__tests__/scoring-calculator-qp-notify.test.ts @@ -104,4 +104,40 @@ describe("processQualifyingEvent — QP change notification", () => { expect(notifyQualifyingPointsUpdate).not.toHaveBeenCalled(); }); + + it("announces newly-eliminated players even when no QP changed (mirror non-scoring-round exit)", async () => { + // A mirror window re-scored on fan-out: nobody's QP changed, but the primary + // bracket reports p2 knocked out in a non-scoring round. The notification must + // still fire, passing the eliminated id through as the 5th arg so the "Knocked + // Out" section isn't dropped on the mirror. + const rows: ResultRow[] = [ + { id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: "100.00", scoringEvent }, + { id: "r2", seasonParticipantId: "p2", placement: null, qualifyingPointsAwarded: "0.00", scoringEvent }, + ]; + const db = makeDb(rows, rows.map((r) => ({ ...r }))); + + await processQualifyingEvent(EVENT_ID, db, { + newlyEliminatedParticipantIds: new Set(["p2"]), + }); + + expect(notifyQualifyingPointsUpdate).toHaveBeenCalledTimes(1); + const [, , , changed, eliminated] = vi.mocked(notifyQualifyingPointsUpdate).mock.calls[0]; + // No QP change this sync. + expect([...(changed as Set)]).toEqual([]); + // The knocked-out player is forwarded to the notifier. + expect([...(eliminated as Set)]).toEqual(["p2"]); + }); + + it("does not notify on a re-sync with no QP change and no eliminations", async () => { + const rows: ResultRow[] = [ + { id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: "100.00", scoringEvent }, + ]; + const db = makeDb(rows, rows.map((r) => ({ ...r }))); + + await processQualifyingEvent(EVENT_ID, db, { + newlyEliminatedParticipantIds: new Set(), + }); + + expect(notifyQualifyingPointsUpdate).not.toHaveBeenCalled(); + }); }); diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index 2d2a834..ab0981a 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -880,6 +880,15 @@ export async function processQualifyingEvent( * which fall back to querying it here. */ canonicalTieCountByPlacement?: Map; + /** + * This window's season_participant ids that were knocked out this sync in a + * non-scoring round. They earn no QP (so they never surface via changed QP), + * but a manager who drafted them should still be told. Threaded down from the + * primary bracket by the fan-out (syncTournamentResults), already translated + * to THIS window's season_participant ids. See the primary path in + * app/services/match-sync/index.ts (newlyEliminatedIds). + */ + newlyEliminatedParticipantIds?: Set; } = {} ): Promise { const db = providedDb || database(); @@ -1035,9 +1044,22 @@ export async function processQualifyingEvent( afterRows.map((r) => ({ id: r.seasonParticipantId, qp: r.qualifyingPointsAwarded })) ); - if (changedParticipantIds.size > 0 && !options.skipNotifications) { + // Players knocked out this sync in a non-scoring round earn no QP, so they never + // appear in changedParticipantIds. Announce them too (mirroring the primary path + // in syncTennisDraw), so a mirror window's "Knocked Out" section isn't dropped. + const eliminatedIds = options.newlyEliminatedParticipantIds ?? new Set(); + if ( + (changedParticipantIds.size > 0 || eliminatedIds.size > 0) && + !options.skipNotifications + ) { try { - await notifyQualifyingPointsUpdate(event.sportsSeasonId, eventId, db, changedParticipantIds); + await notifyQualifyingPointsUpdate( + event.sportsSeasonId, + eventId, + db, + changedParticipantIds, + eliminatedIds, + ); } catch (error) { logger.error(`[ScoringCalculator] QP Discord notification failed for event ${eventId}:`, error); } diff --git a/app/services/__tests__/sync-tournament-results.test.ts b/app/services/__tests__/sync-tournament-results.test.ts index 01bebfb..af97012 100644 --- a/app/services/__tests__/sync-tournament-results.test.ts +++ b/app/services/__tests__/sync-tournament-results.test.ts @@ -274,6 +274,12 @@ vi.mock("drizzle-orm", async () => { }, // eslint-disable-next-line @typescript-eslint/no-explicit-any and: (...preds: any[]) => (row: any) => preds.every((p) => p(row)), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + inArray: (col: any, vals: any[]) => { + const key = colKey(col); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (row: any) => vals.includes(row[key]); + }, }; }); @@ -987,6 +993,80 @@ describe("syncMajorFromPrimaryEvent", () => { expect(tieMap.get(5)).toBe(4); // QF tier spans 4 }); + it("fans a non-scoring-round elimination out to each mirror window (translated to its own season_participant id)", async () => { + // The reported bug: a player knocked out in a non-scoring round earns no QP, so + // canonical promotion skips them (null placement) and the mirror can't detect the + // knockout locally. The primary passes its eliminated season_participant id + // (sp-PX) down; syncMajorFromPrimaryEvent must translate it to the canonical + // participant (cp-X) and each mirror window must re-translate to ITS own + // season_participant (sp-MX) before handing it to processQualifyingEvent. + const state = seedBasicState({ + scoringEvents: [ + { id: "ev-PRIMARY", sportsSeasonId: "ss-P", tournamentId: "t-1", name: "Wimbledon" }, + { id: "ev-MIRROR", sportsSeasonId: "ss-M", tournamentId: "t-1", name: "Wimbledon" }, + ], + seasonParticipants: [ + // Placed finalist, present on both windows. + { id: "sp-PA", sportsSeasonId: "ss-P", participantId: "cp-A", name: "A" }, + { id: "sp-MA", sportsSeasonId: "ss-M", participantId: "cp-A", name: "A" }, + // Knocked-out player: different season_participant row per window, same + // canonical participant cp-X. + { id: "sp-PX", sportsSeasonId: "ss-P", participantId: "cp-X", name: "X" }, + { id: "sp-MX", sportsSeasonId: "ss-M", participantId: "cp-X", name: "X" }, + ], + }); + const db = makeFakeDb(state); + vi.mocked(database).mockReturnValue(db as never); + vi.mocked(processQualifyingEvent).mockResolvedValue(undefined); + vi.mocked(completeScoringEvent).mockResolvedValue(undefined as never); + vi.mocked(recalculateAffectedLeagues).mockResolvedValue(undefined); + + vi.mocked(getScoringEventById).mockResolvedValue({ + id: "ev-PRIMARY", + sportsSeasonId: "ss-P", + tournamentId: "t-1", + name: "Wimbledon", + } as never); + + // Primary derived results promote only the PLACED player. cp-X (the non-scoring + // loser) has no placement and is not promoted to canonical — exactly why the + // mirror needs the elimination threaded separately. + vi.mocked(getEventResults).mockResolvedValue([ + { + placement: 1, + rawScore: "1", + notParticipating: false, + seasonParticipantId: "sp-PA", + seasonParticipant: { participantId: "cp-A" }, + }, + ] as never); + + vi.mocked(upsertTournamentResult).mockImplementation( + async (data: { tournamentId: string; participantId: string; placement?: number | null; rawScore?: string | null }) => { + state.tournamentResults.push({ + tournamentId: data.tournamentId, + participantId: data.participantId, + placement: data.placement ?? null, + rawScore: data.rawScore ?? null, + }); + return data as never; + } + ); + + await syncMajorFromPrimaryEvent("ev-PRIMARY", { + newlyEliminatedParticipantIds: new Set(["sp-PX"]), + }); + + const mirrorCall = (processQualifyingEvent as Mock).mock.calls.find( + (c) => c[0] === "ev-MIRROR" + ); + expect(mirrorCall).toBeDefined(); + if (!mirrorCall) return; + const eliminated = mirrorCall[2].newlyEliminatedParticipantIds as Set; + // cp-X → the MIRROR's own season_participant id, not the primary's. + expect([...eliminated]).toEqual(["sp-MX"]); + }); + it("throws when the primary event is not linked to a tournament", async () => { const db = makeFakeDb(seedBasicState()); vi.mocked(database).mockReturnValue(db as never); diff --git a/app/services/match-sync/index.ts b/app/services/match-sync/index.ts index d52fff2..891739d 100644 --- a/app/services/match-sync/index.ts +++ b/app/services/match-sync/index.ts @@ -611,9 +611,16 @@ export async function syncTennisDraw(eventId: string): Promise { eventId, eventName: event.name ?? undefined, }); + // Players knocked out this sync in a non-scoring round (rounds 1–3 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 }, + { markComplete: false, newlyEliminatedParticipantIds: newlyEliminatedIds }, ); // Announce QP changes for the primary window. Sibling windows are announced by @@ -622,10 +629,7 @@ export async function syncTennisDraw(eventId: string): Promise { // outside the rescore transaction so the webhook HTTP call neither holds the // transaction open nor rolls back the score if Discord fails. // - // Also announce players knocked out this sync in a non-scoring round (rounds - // 1–3 of a Grand Slam), who earn no QP and so never appear via changedParticipantIds. // Fire even when no QP changed, so an early-round elimination is still surfaced. - const newlyEliminatedIds = new Set(newlyDecidedLoserIds); if (changedParticipantIds.size > 0 || newlyEliminatedIds.size > 0) { try { await notifyQualifyingPointsUpdate( diff --git a/app/services/sync-tournament-results.ts b/app/services/sync-tournament-results.ts index 792283d..0410f45 100644 --- a/app/services/sync-tournament-results.ts +++ b/app/services/sync-tournament-results.ts @@ -60,6 +60,16 @@ export interface SyncOptions { * stage. Omitted for golf-style majors (no bracket) → canonical row-count is used. */ tieCountByPlacement?: Map; + /** + * Canonical participant ids (participants.id) knocked out this sync in a + * non-scoring round on the primary bracket. Threaded through so each mirror + * window can translate them to its own season_participant ids and announce the + * "Knocked Out" section — these players earn no QP and so are otherwise invisible + * to the mirror (a null-placement filler row indistinguishable from "not yet + * played"). Canonical ids because they cross window boundaries; each window holds + * a different season_participant row for the same canonical participant. + */ + newlyEliminatedCanonicalParticipantIds?: Set; } /** @@ -87,6 +97,7 @@ export async function syncTournamentResults( skipEventId, skipNotifications = false, tieCountByPlacement, + newlyEliminatedCanonicalParticipantIds, } = options; const report: SyncReport = { @@ -216,13 +227,28 @@ export async function syncTournamentResults( } } - // 3d. Delegate to scoring engine inside the same transaction. + // 3d. Translate the primary's newly-eliminated canonical participants into + // THIS window's season_participant ids (same participantId → sp.id key the + // canonical result copy uses above), so processQualifyingEvent can announce + // the "Knocked Out" section for players drafted in this window's leagues. + let windowEliminatedSpIds: Set | undefined; + if (newlyEliminatedCanonicalParticipantIds?.size) { + windowEliminatedSpIds = new Set(); + for (const sp of rosters) { + if (sp.participantId && newlyEliminatedCanonicalParticipantIds.has(sp.participantId)) { + windowEliminatedSpIds.add(sp.id); + } + } + } + + // 3e. Delegate to scoring engine inside the same transaction. await processQualifyingEvent(ev.id, tx, { skipNotifications, canonicalTieCountByPlacement: effectiveTieCountByPlacement, + newlyEliminatedParticipantIds: windowEliminatedSpIds, }); - // 3e. Mark the window event complete (final-results sync only). This is + // 3f. 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. // Skip windows already complete so a re-run (e.g. a backfill) doesn't @@ -297,9 +323,23 @@ export async function syncTournamentResults( */ export async function syncMajorFromPrimaryEvent( primaryEventId: string, - options: { markComplete?: boolean; skipNotifications?: boolean } = {} + options: { + markComplete?: boolean; + skipNotifications?: boolean; + /** + * Primary-window season_participant ids knocked out this sync in a non-scoring + * round (from the primary bracket's newlyDecidedLoserIds). Translated to + * canonical participant ids here, then fanned out so each mirror window can + * announce its own "Knocked Out" section. + */ + newlyEliminatedParticipantIds?: Set; + } = {} ): Promise { - const { markComplete = false, skipNotifications = false } = options; + const { + markComplete = false, + skipNotifications = false, + newlyEliminatedParticipantIds, + } = options; const primaryEvent = await getScoringEventById(primaryEventId); if (!primaryEvent) { @@ -372,11 +412,32 @@ export async function syncMajorFromPrimaryEvent( db ); + // Translate the primary window's eliminated season_participant ids into canonical + // participant ids so the fan-out can re-key them per mirror window. These are + // non-scoring-round losers (null placement), so they were skipped from canonical + // promotion above — resolve them directly from season_participants, not from + // tournament_results. + let newlyEliminatedCanonicalParticipantIds: Set | undefined; + if (newlyEliminatedParticipantIds?.size) { + const eliminatedRows = await db + .select({ participantId: schema.seasonParticipants.participantId }) + .from(schema.seasonParticipants) + .where( + inArray(schema.seasonParticipants.id, [...newlyEliminatedParticipantIds]) + ); + newlyEliminatedCanonicalParticipantIds = new Set( + eliminatedRows + .map((r) => r.participantId) + .filter((id): id is string => id !== null) + ); + } + return syncTournamentResults(tournamentId, { markComplete, skipEventId: primaryEventId, skipNotifications, tieCountByPlacement, + newlyEliminatedCanonicalParticipantIds, }); } @@ -434,7 +495,15 @@ async function deriveStructuralTieSpanForBracket( */ export async function fanOutMajorIfPrimary( event: { id: string; isPrimary: boolean; tournamentId: string | null }, - options: { markComplete?: boolean } = {} + options: { + markComplete?: boolean; + /** + * Primary-window season_participant ids knocked out this sync in a non-scoring + * round. Forwarded to the fan-out so each mirror window announces its own + * "Knocked Out" section. + */ + newlyEliminatedParticipantIds?: Set; + } = {} ): Promise { if (!event.isPrimary || !event.tournamentId) return; try {