diff --git a/app/models/__tests__/process-match-result.test.ts b/app/models/__tests__/process-match-result.test.ts index fdb5d6d..df825ee 100644 --- a/app/models/__tests__/process-match-result.test.ts +++ b/app/models/__tests__/process-match-result.test.ts @@ -399,6 +399,7 @@ describe("isLoserNotifiable", () => { }); }); + describe("processPlayoffEvent - NBA Play-In Round 1 loserAdvances fix", () => { function makePlayInDb(matches: object[]) { const insertedRows: Array<{ participantId: string; finalPosition: number; isPartialScore: boolean }> = []; diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index 4df2bbd..f5cb685 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -623,6 +623,7 @@ export function isLoserNotifiable( return scoreChanged || finalizedLoserIds.has(loserId); } + export function getGuaranteedMinimumPosition( round: string, bracketTemplateId: string | null | undefined, @@ -1826,12 +1827,17 @@ export async function recalculateAffectedLeagues( ); // Build scored matches for the notification. - // Winners only appear if their team's score changed (they earned points this round). - // Losers appear if their team's score changed OR they were definitively eliminated - // (finalPosition set, non-partial) — the latter catches 0-pt eliminations that - // produce no score delta. Losers who advance to another match (loserAdvances=true, - // e.g. NBA 7v8 → PIR2) have no finalized result and no score change, so they're - // correctly suppressed. + // A match is included only when it is "notable": winner earned points this round OR + // the loser is notifiable (eliminated / scored). A drafted winner that merely advanced + // without earning points is NOT enough on its own. + // On any included match, the winning team's owner is shown in the embed (winnerUsername), + // but Discord-pinged only when they actually scored (winnerDiscordUserId gated on + // winnerScoreChanged) — advancing-only wins don't warrant a ping. + // Losers appear if their score changed or they were definitively eliminated (finalPosition + // set, non-partial); losers who advance to another match (loserAdvances=true, e.g. NBA + // 7v8 → PIR2) are correctly suppressed. + // Only entries with at least one displayable username are kept, so scoredMatches always + // reflects exactly what will be shown in the embed. let scoredMatches: ScoredMatch[] | undefined; if (allCompletedMatches.length > 0) { const relevant = allCompletedMatches.filter( @@ -1857,15 +1863,18 @@ export async function recalculateAffectedLeagues( const winnerOwnerId = lookupOwner(m.winnerId); const loserOwnerId = lookupOwner(m.loserId); const showLoser = isLoserNotifiable(m.loserId, loserTeamId, changedTeamIds, finalizedLoserIds); - return { - winnerName: m.winnerName ?? "", - loserName: m.loserName ?? "", - winnerUsername: winnerScoreChanged && winnerOwnerId ? usernameByUserId.get(winnerOwnerId) : undefined, - loserUsername: showLoser && loserOwnerId ? usernameByUserId.get(loserOwnerId) : undefined, - winnerDiscordUserId: winnerScoreChanged && winnerOwnerId ? discordIdByUserId.get(winnerOwnerId) : undefined, - loserDiscordUserId: showLoser && loserOwnerId ? discordIdByUserId.get(loserOwnerId) : undefined, - }; - }); + return { m, winnerScoreChanged, showLoser, winnerOwnerId, loserOwnerId }; + }) + .filter((x) => x.winnerScoreChanged || x.showLoser) + .map((x) => ({ + winnerName: x.m.winnerName ?? "", + loserName: x.m.loserName ?? "", + winnerUsername: x.winnerOwnerId ? usernameByUserId.get(x.winnerOwnerId) : undefined, + loserUsername: x.showLoser && x.loserOwnerId ? usernameByUserId.get(x.loserOwnerId) : undefined, + winnerDiscordUserId: x.winnerScoreChanged && x.winnerOwnerId ? discordIdByUserId.get(x.winnerOwnerId) : undefined, + loserDiscordUserId: x.showLoser && x.loserOwnerId ? discordIdByUserId.get(x.loserOwnerId) : undefined, + })) + .filter((m) => m.winnerUsername !== undefined || m.loserUsername !== undefined); } } diff --git a/app/services/__tests__/discord.test.ts b/app/services/__tests__/discord.test.ts index 59f0be5..45d97a3 100644 --- a/app/services/__tests__/discord.test.ts +++ b/app/services/__tests__/discord.test.ts @@ -186,6 +186,29 @@ describe("sendStandingsUpdateNotification", () => { expect(desc).toContain("• **Sporting (christhrowsrocks)** def. Bodø/Glimt (apatel)"); }); + it("shows winner's owner even when winner only advanced (loser was eliminated)", async () => { + // Mirrors the Brazil/Japan scenario: Brazil advanced without earning points, + // Japan was eliminated. winnerUsername is shown in the embed; winnerDiscordUserId + // is left unset (no ping) because the winner did not score this round. + await sendStandingsUpdateNotification({ + webhookUrl: WEBHOOK_URL, + seasonName: "Rumble League 2026", + standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }], + previousStandings: new Map([["a", 100]]), + scoredMatches: [ + { + winnerName: "Brazil", + loserName: "Japan", + winnerUsername: "someManager", + loserUsername: "ikyn", + }, + ], + }); + + const desc = getDescription(); + expect(desc).toContain("• **Brazil (someManager)** def. Japan (ikyn)"); + }); + it("omits scored matches where neither side has a username", async () => { await sendStandingsUpdateNotification({ webhookUrl: WEBHOOK_URL,