From 1d4f78293fb8044a133043be0acbc0b63aac20e6 Mon Sep 17 00:00:00 2001 From: hz <1766264+zeeshaun@users.noreply.github.com> Date: Sun, 31 May 2026 05:22:57 -0500 Subject: [PATCH 1/2] Respect admin mode for expired tournament check-ins --- src/matches/jobs/CancelExpiredMatches.spec.ts | 154 ++++++++++++++++++ src/matches/jobs/CancelExpiredMatches.ts | 72 +++++++- 2 files changed, 217 insertions(+), 9 deletions(-) create mode 100644 src/matches/jobs/CancelExpiredMatches.spec.ts diff --git a/src/matches/jobs/CancelExpiredMatches.spec.ts b/src/matches/jobs/CancelExpiredMatches.spec.ts new file mode 100644 index 00000000..1283dec1 --- /dev/null +++ b/src/matches/jobs/CancelExpiredMatches.spec.ts @@ -0,0 +1,154 @@ +import { CancelExpiredMatches } from "./CancelExpiredMatches"; + +const expiredTournamentMatch = (overrides: Record = {}) => ({ + id: "match-1", + is_tournament_match: true, + options: { + match_mode: "auto", + }, + lineup_1: { + id: "lineup-1", + is_ready: false, + }, + lineup_2: { + id: "lineup-2", + is_ready: false, + }, + ...overrides, +}); + +describe("CancelExpiredMatches", () => { + const logger = { + log: jest.fn(), + }; + const hasura = { + mutation: jest.fn(), + query: jest.fn(), + }; + const notifications = { + send: jest.fn(), + }; + + let job: CancelExpiredMatches; + + beforeEach(() => { + jest.clearAllMocks(); + hasura.mutation.mockResolvedValue({ + update_matches: { + affected_rows: 0, + }, + }); + hasura.query.mockResolvedValue({ + matches: [], + }); + job = new CancelExpiredMatches( + logger as any, + hasura as any, + notifications as any, + ); + }); + + it("requests organizer attention for admin-mode tournament matches when neither lineup is ready", async () => { + hasura.query.mockResolvedValue({ + matches: [ + expiredTournamentMatch({ + options: { + match_mode: "admin", + }, + }), + ], + }); + + await expect(job.process()).resolves.toBe(1); + + expect(hasura.mutation).toHaveBeenCalledWith( + expect.objectContaining({ + update_matches_by_pk: expect.objectContaining({ + __args: expect.objectContaining({ + pk_columns: { + id: "match-1", + }, + _set: { + cancels_at: null, + }, + }), + }), + }), + ); + expect(hasura.mutation).not.toHaveBeenCalledWith( + expect.objectContaining({ + update_matches_by_pk: expect.objectContaining({ + __args: expect.objectContaining({ + _set: expect.objectContaining({ + status: "Forfeit", + }), + }), + }), + }), + ); + expect(notifications.send).toHaveBeenCalledWith("MatchSupport", { + message: "Tournament match requires admin attention: match-1", + title: "Tournament match requires attention", + role: "tournament_organizer", + entity_id: "match-1", + }); + }); + + it("forfeits auto-mode tournament matches when neither lineup is ready", async () => { + jest.spyOn(Math, "random").mockReturnValue(0.25); + hasura.query.mockResolvedValue({ + matches: [expiredTournamentMatch()], + }); + + await job.process(); + + expect(hasura.mutation).toHaveBeenCalledWith( + expect.objectContaining({ + update_matches_by_pk: expect.objectContaining({ + __args: expect.objectContaining({ + pk_columns: { + id: "match-1", + }, + _set: { + status: "Forfeit", + winning_lineup_id: "lineup-1", + }, + }), + }), + }), + ); + expect(notifications.send).not.toHaveBeenCalled(); + }); + + it("forfeits to the ready lineup even in admin mode", async () => { + hasura.query.mockResolvedValue({ + matches: [ + expiredTournamentMatch({ + options: { + match_mode: "admin", + }, + lineup_2: { + id: "lineup-2", + is_ready: true, + }, + }), + ], + }); + + await job.process(); + + expect(hasura.mutation).toHaveBeenCalledWith( + expect.objectContaining({ + update_matches_by_pk: expect.objectContaining({ + __args: expect.objectContaining({ + _set: { + status: "Forfeit", + winning_lineup_id: "lineup-2", + }, + }), + }), + }), + ); + expect(notifications.send).not.toHaveBeenCalled(); + }); +}); diff --git a/src/matches/jobs/CancelExpiredMatches.ts b/src/matches/jobs/CancelExpiredMatches.ts index ab89f3f1..f4a6e97e 100644 --- a/src/matches/jobs/CancelExpiredMatches.ts +++ b/src/matches/jobs/CancelExpiredMatches.ts @@ -3,12 +3,14 @@ import { WorkerHost } from "@nestjs/bullmq"; import { MatchQueues } from "../enums/MatchQueues"; import { UseQueue } from "../../utilities/QueueProcessors"; import { HasuraService } from "../../hasura/hasura.service"; +import { NotificationsService } from "../../notifications/notifications.service"; @UseQueue("Matches", MatchQueues.ScheduledMatches) export class CancelExpiredMatches extends WorkerHost { constructor( private readonly logger: Logger, private readonly hasura: HasuraService, + private readonly notifications: NotificationsService, ) { super(); } @@ -50,25 +52,37 @@ export class CancelExpiredMatches extends WorkerHost { const tournamentMatches = await this.getTournamentMatches(); for (const tournamentMatch of tournamentMatches) { - await this.forfeitMatch(tournamentMatch); + await this.handleExpiredTournamentMatch(tournamentMatch); } - const totalCanceledMatches = + const totalExpiredMatches = update_matches.affected_rows + tournamentMatches.length; - if (totalCanceledMatches > 0) { - this.logger.log(`canceled ${totalCanceledMatches} matches`); + if (totalExpiredMatches > 0) { + this.logger.log(`processed ${totalExpiredMatches} expired matches`); } - return totalCanceledMatches; + return totalExpiredMatches; + } + + private async handleExpiredTournamentMatch( + match: Awaited>[number], + ) { + const hasReadyLineup = match.lineup_1.is_ready || match.lineup_2.is_ready; + const isAdminMode = match.options?.match_mode === "admin"; + + if (!hasReadyLineup && isAdminMode) { + await this.requestOrganizerAttention(match.id); + return; + } + + await this.forfeitMatch(match); } private async forfeitMatch( match: Awaited>[number], ) { - const winningLineupId = match.lineup_1.is_ready - ? match.lineup_1.id - : match.lineup_2.id; - void this.hasura.mutation({ + const winningLineupId = this.getWinningLineupId(match); + await this.hasura.mutation({ update_matches_by_pk: { __args: { pk_columns: { @@ -84,6 +98,43 @@ export class CancelExpiredMatches extends WorkerHost { }); } + private getWinningLineupId( + match: Awaited>[number], + ) { + if (match.lineup_1.is_ready) { + return match.lineup_1.id; + } + + if (match.lineup_2.is_ready) { + return match.lineup_2.id; + } + + return Math.random() < 0.5 ? match.lineup_1.id : match.lineup_2.id; + } + + private async requestOrganizerAttention(matchId: string) { + await this.hasura.mutation({ + update_matches_by_pk: { + __args: { + pk_columns: { + id: matchId, + }, + _set: { + cancels_at: null, + }, + }, + __typename: true, + }, + }); + + await this.notifications.send("MatchSupport", { + message: `Tournament match requires admin attention: ${matchId}`, + title: "Tournament match requires attention", + role: "tournament_organizer", + entity_id: matchId, + }); + } + private async getTournamentMatches() { const { matches } = await this.hasura.query({ matches: { @@ -110,6 +161,9 @@ export class CancelExpiredMatches extends WorkerHost { }, id: true, is_tournament_match: true, + options: { + match_mode: true, + }, lineup_1: { id: true, is_ready: true, From 61ef7e33c5d082d1156562a9f82c8bfef695cf3b Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Tue, 2 Jun 2026 19:06:21 -0400 Subject: [PATCH 2/2] review: link + de-dupe organizer notification, document coin-toss - Link the MatchSupport notification to the match (web domain) and color it red, matching the existing match-support convention. - Skip sending when an unread MatchSupport notification already exists for the match, so cancels_at nulling is no longer the sole de-dupe. - Comment the auto-mode random winner fallback. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/matches/jobs/CancelExpiredMatches.spec.ts | 110 ++++++++++++------ src/matches/jobs/CancelExpiredMatches.ts | 48 +++++++- 2 files changed, 120 insertions(+), 38 deletions(-) diff --git a/src/matches/jobs/CancelExpiredMatches.spec.ts b/src/matches/jobs/CancelExpiredMatches.spec.ts index 1283dec1..08526487 100644 --- a/src/matches/jobs/CancelExpiredMatches.spec.ts +++ b/src/matches/jobs/CancelExpiredMatches.spec.ts @@ -1,4 +1,5 @@ import { CancelExpiredMatches } from "./CancelExpiredMatches"; +import { DISCORD_COLORS } from "../../notifications/utilities/constants"; const expiredTournamentMatch = (overrides: Record = {}) => ({ id: "match-1", @@ -28,36 +29,50 @@ describe("CancelExpiredMatches", () => { const notifications = { send: jest.fn(), }; + const configService = { + get: jest.fn(), + }; let job: CancelExpiredMatches; + let tournamentMatches: any[]; + let pendingNotificationCount: number; beforeEach(() => { jest.clearAllMocks(); + tournamentMatches = []; + pendingNotificationCount = 0; hasura.mutation.mockResolvedValue({ update_matches: { affected_rows: 0, }, }); - hasura.query.mockResolvedValue({ - matches: [], + hasura.query.mockImplementation(async (query: any) => { + if (query.notifications_aggregate) { + return { + notifications_aggregate: { + aggregate: { count: pendingNotificationCount }, + }, + }; + } + return { matches: tournamentMatches }; }); + configService.get.mockReturnValue({ webDomain: "https://example.com" }); job = new CancelExpiredMatches( logger as any, hasura as any, notifications as any, + configService as any, ); }); it("requests organizer attention for admin-mode tournament matches when neither lineup is ready", async () => { - hasura.query.mockResolvedValue({ - matches: [ - expiredTournamentMatch({ - options: { - match_mode: "admin", - }, - }), - ], - }); + tournamentMatches = [ + expiredTournamentMatch({ + options: { + match_mode: "admin", + }, + }), + ]; await expect(job.process()).resolves.toBe(1); @@ -86,19 +101,50 @@ describe("CancelExpiredMatches", () => { }), }), ); - expect(notifications.send).toHaveBeenCalledWith("MatchSupport", { - message: "Tournament match requires admin attention: match-1", - title: "Tournament match requires attention", - role: "tournament_organizer", - entity_id: "match-1", - }); + expect(notifications.send).toHaveBeenCalledWith( + "MatchSupport", + expect.objectContaining({ + message: expect.stringContaining( + 'href="https://example.com/matches/match-1"', + ), + title: "Tournament match requires attention", + role: "tournament_organizer", + entity_id: "match-1", + }), + undefined, + DISCORD_COLORS.RED, + ); + }); + + it("does not re-notify when an organizer notification is already pending", async () => { + pendingNotificationCount = 1; + tournamentMatches = [ + expiredTournamentMatch({ + options: { + match_mode: "admin", + }, + }), + ]; + + await job.process(); + + expect(hasura.mutation).toHaveBeenCalledWith( + expect.objectContaining({ + update_matches_by_pk: expect.objectContaining({ + __args: expect.objectContaining({ + _set: { + cancels_at: null, + }, + }), + }), + }), + ); + expect(notifications.send).not.toHaveBeenCalled(); }); it("forfeits auto-mode tournament matches when neither lineup is ready", async () => { jest.spyOn(Math, "random").mockReturnValue(0.25); - hasura.query.mockResolvedValue({ - matches: [expiredTournamentMatch()], - }); + tournamentMatches = [expiredTournamentMatch()]; await job.process(); @@ -121,19 +167,17 @@ describe("CancelExpiredMatches", () => { }); it("forfeits to the ready lineup even in admin mode", async () => { - hasura.query.mockResolvedValue({ - matches: [ - expiredTournamentMatch({ - options: { - match_mode: "admin", - }, - lineup_2: { - id: "lineup-2", - is_ready: true, - }, - }), - ], - }); + tournamentMatches = [ + expiredTournamentMatch({ + options: { + match_mode: "admin", + }, + lineup_2: { + id: "lineup-2", + is_ready: true, + }, + }), + ]; await job.process(); diff --git a/src/matches/jobs/CancelExpiredMatches.ts b/src/matches/jobs/CancelExpiredMatches.ts index f4a6e97e..7e893620 100644 --- a/src/matches/jobs/CancelExpiredMatches.ts +++ b/src/matches/jobs/CancelExpiredMatches.ts @@ -1,18 +1,25 @@ import { Logger } from "@nestjs/common"; import { WorkerHost } from "@nestjs/bullmq"; +import { ConfigService } from "@nestjs/config"; import { MatchQueues } from "../enums/MatchQueues"; import { UseQueue } from "../../utilities/QueueProcessors"; import { HasuraService } from "../../hasura/hasura.service"; import { NotificationsService } from "../../notifications/notifications.service"; +import { AppConfig } from "../../configs/types/AppConfig"; +import { DISCORD_COLORS } from "../../notifications/utilities/constants"; @UseQueue("Matches", MatchQueues.ScheduledMatches) export class CancelExpiredMatches extends WorkerHost { + private readonly appConfig: AppConfig; + constructor( private readonly logger: Logger, private readonly hasura: HasuraService, private readonly notifications: NotificationsService, + private readonly configService: ConfigService, ) { super(); + this.appConfig = this.configService.get("app"); } async process(): Promise { const { update_matches } = await this.hasura.mutation({ @@ -109,6 +116,9 @@ export class CancelExpiredMatches extends WorkerHost { return match.lineup_2.id; } + // Neither side checked in. In auto mode there is no one watching the + // bracket, so coin-toss a winner to keep the tournament moving rather + // than stalling it (admin mode routes to a human instead). return Math.random() < 0.5 ? match.lineup_1.id : match.lineup_2.id; } @@ -127,12 +137,40 @@ export class CancelExpiredMatches extends WorkerHost { }, }); - await this.notifications.send("MatchSupport", { - message: `Tournament match requires admin attention: ${matchId}`, - title: "Tournament match requires attention", - role: "tournament_organizer", - entity_id: matchId, + if (await this.hasPendingOrganizerNotification(matchId)) { + return; + } + + await this.notifications.send( + "MatchSupport", + { + message: `Tournament match requires admin attention ${matchId}`, + title: "Tournament match requires attention", + role: "tournament_organizer", + entity_id: matchId, + }, + undefined, + DISCORD_COLORS.RED, + ); + } + + private async hasPendingOrganizerNotification(matchId: string) { + const { notifications_aggregate } = await this.hasura.query({ + notifications_aggregate: { + __args: { + where: { + entity_id: { _eq: matchId }, + type: { _eq: "MatchSupport" }, + is_read: { _eq: false }, + }, + }, + aggregate: { + count: true, + }, + }, }); + + return notifications_aggregate.aggregate.count > 0; } private async getTournamentMatches() {