From fb10d7946cfe8883a4dad9cc0c2128c5fbd8cce8 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 14:16:48 -0400 Subject: [PATCH 1/5] bug: socket pong, ban-only teammate notices, tighter player delete, support notification text - reply to the web client's ping with a pong so a dead-but-OPEN socket is detectable - only a ban notifies former team-mates; mutes/gags/silences stay between the player and staff - only administrators can delete a player row - check-in error names team admins as well as the captain - name change requests link the player's profile (absolute url, Discord-routed) - match support notification names the requester, fixes the title typo, and requires the caller be in the lineup --- .../default/tables/public_players.yaml | 2 +- src/hasura/metadata-permissions.spec.ts | 12 +++ src/matches/matches.controller.spec.ts | 81 ++++++++++++++++++- src/matches/matches.controller.ts | 14 +++- .../notifications.service.spec.ts | 79 ++++++++++++++++++ src/notifications/notifications.service.ts | 32 ++++---- src/sockets/sockets.gateway.spec.ts | 72 +++++++++++++++++ src/sockets/sockets.gateway.ts | 6 ++ src/system/system.controller.spec.ts | 34 +++++++- src/system/system.controller.ts | 2 +- .../tournaments.controller.spec.ts | 2 +- src/tournaments/tournaments.controller.ts | 4 +- 12 files changed, 312 insertions(+), 28 deletions(-) create mode 100644 src/sockets/sockets.gateway.spec.ts diff --git a/hasura/metadata/databases/default/tables/public_players.yaml b/hasura/metadata/databases/default/tables/public_players.yaml index bf7416ee..c8c6bf0b 100644 --- a/hasura/metadata/databases/default/tables/public_players.yaml +++ b/hasura/metadata/databases/default/tables/public_players.yaml @@ -633,7 +633,7 @@ update_permissions: _eq: X-Hasura-User-Id comment: "" delete_permissions: - - role: match_organizer + - role: administrator permission: filter: {} comment: "" diff --git a/src/hasura/metadata-permissions.spec.ts b/src/hasura/metadata-permissions.spec.ts index 1074fd29..dd38315f 100644 --- a/src/hasura/metadata-permissions.spec.ts +++ b/src/hasura/metadata-permissions.spec.ts @@ -137,6 +137,18 @@ describe("hasura table metadata", () => { expect(problems).toEqual([]); }); + // A player row is the account itself: every match, stat and sanction hangs + // off it, so removing one is not a match organizer's call. + it("only lets an administrator delete a player", () => { + const players = tables.find(({ file }) => file === "public_players.yaml"); + + expect( + (players?.metadata?.delete_permissions ?? []).map( + (entry: { role: string }) => entry.role, + ), + ).toEqual(["administrator"]); + }); + it("never lists the same name as both a column and a computed field", () => { const problems: Array = []; diff --git a/src/matches/matches.controller.spec.ts b/src/matches/matches.controller.spec.ts index c4fe6028..ce035ce9 100644 --- a/src/matches/matches.controller.spec.ts +++ b/src/matches/matches.controller.spec.ts @@ -13,25 +13,30 @@ describe("MatchesController", () => { isOrganizer: jest.Mock; rebootOnDemandServer: jest.Mock; }; + let hasura: { query: jest.Mock }; + let notifications: { send: jest.Mock }; beforeEach(() => { matchAssistant = { isOrganizer: jest.fn(), rebootOnDemandServer: jest.fn(), }; + hasura = { query: jest.fn() }; + notifications = { send: jest.fn().mockResolvedValue(undefined) }; controller = new MatchesController( {} as any, - {} as any, + hasura as any, {} as any, { - get: jest.fn(() => ({})), + get: jest.fn(() => ({ webDomain: "https://5stack.test" })), } as any, {} as any, matchAssistant as any, {} as any, {} as any, {} as any, + notifications as any, {} as any, {} as any, {} as any, @@ -52,7 +57,6 @@ describe("MatchesController", () => { {} as any, {} as any, {} as any, - {} as any ); }); @@ -82,4 +86,75 @@ describe("MatchesController", () => { expect(matchAssistant.rebootOnDemandServer).toHaveBeenCalledWith("match-1"); }); + + describe("callForOrganizer", () => { + const matchId = "00000000-0000-0000-0000-000000000001"; + + const callForOrganizer = (user: Record) => + controller.callForOrganizer({ + match_id: matchId, + user: { steam_id: "76561198000000001", ...user } as any, + }); + + const sent = () => { + const [type, notification] = notifications.send.mock.calls[0]; + return { type, ...notification }; + }; + + beforeEach(() => { + hasura.query.mockResolvedValue({ + matches_by_pk: { is_in_lineup: true, requested_organizer: false }, + }); + }); + + it("names the requester without linking them", async () => { + await callForOrganizer({ name: "keith" }); + + const notification = sent(); + expect(notification.type).toBe("MatchSupport"); + expect(notification.message).toContain( + "<b>keith</b> requested assistance in match", + ); + expect(notification.message.match(/href="([^"]+)"/)?.[1]).toBe( + `https://5stack.test/matches/${matchId}`, + ); + }); + + it("falls back to the steam id when the requester has no name", async () => { + await callForOrganizer({ name: undefined }); + + expect(sent().message).toContain( + "76561198000000001 requested assistance", + ); + }); + + it("titles the notification without the old typo", async () => { + await callForOrganizer({ name: "keith" }); + + expect(sent().title).toBe("Match Assistance Required"); + expect(sent().message).not.toContain("Assistanced"); + }); + + it("rejects someone who is not playing in the match", async () => { + hasura.query.mockResolvedValue({ + matches_by_pk: { is_in_lineup: false, requested_organizer: false }, + }); + + await expect(callForOrganizer({ name: "keith" })).rejects.toThrow( + "only players in this match can contact support", + ); + expect(notifications.send).not.toHaveBeenCalled(); + }); + + it("does not ask twice while a request is still open", async () => { + hasura.query.mockResolvedValue({ + matches_by_pk: { is_in_lineup: true, requested_organizer: true }, + }); + + await expect(callForOrganizer({ name: "keith" })).resolves.toEqual({ + success: true, + }); + expect(notifications.send).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/matches/matches.controller.ts b/src/matches/matches.controller.ts index 01a6d990..e2fc7029 100644 --- a/src/matches/matches.controller.ts +++ b/src/matches/matches.controller.ts @@ -2537,11 +2537,21 @@ export class MatchesController { }; } + if (!match.is_in_lineup) { + throw Error("only players in this match can contact support"); + } + + // The requester stays plain text: notificationUrl takes the first href as + // where the push lands, and that has to be the match. + const requester = NotificationsService.escapeHtml( + data.user.name ?? data.user.steam_id, + ); + void this.notifications.send( "MatchSupport", { - message: `Match Assistanced Required ${data.match_id}`, - title: "Match Assistanced Required", + message: `${requester} requested assistance in match ${data.match_id}`, + title: "Match Assistance Required", role: "match_organizer", entity_id: data.match_id, }, diff --git a/src/notifications/notifications.service.spec.ts b/src/notifications/notifications.service.spec.ts index 3c4f841a..e4a4256b 100644 --- a/src/notifications/notifications.service.spec.ts +++ b/src/notifications/notifications.service.spec.ts @@ -174,3 +174,82 @@ describe("CS2 build notices", () => { expect(NotificationsService.truncateDiscord("short")).toBe("short"); }); }); + +describe("NotificationsService", () => { + const webDomain = "https://5stack.test"; + let service: NotificationsService; + let postgres: { query: jest.Mock }; + let hasura: { query: jest.Mock; mutation: jest.Mock }; + let notifyPlayers: jest.SpyInstance; + + const sanction = (type: string) => ({ + sanctionId: "sanction-1", + steamId: "76561198000000001", + type, + reason: "cheating", + }); + + beforeEach(() => { + postgres = { + query: jest.fn().mockResolvedValue([{ steam_id: "76561198000000002" }]), + }; + hasura = { + query: jest.fn().mockResolvedValue({ players_by_pk: { name: "keith" } }), + mutation: jest.fn().mockResolvedValue({}), + }; + + service = new NotificationsService( + hasura as any, + postgres as any, + { log: jest.fn(), warn: jest.fn(), error: jest.fn() } as any, + { get: jest.fn(() => ({ webDomain })) } as any, + {} as any, + {} as any, + {} as any, + {} as any, + ); + + notifyPlayers = jest.spyOn(service, "notifyPlayers").mockResolvedValue(1); + }); + + describe("playerProfileLink", () => { + it("links to the absolute profile url", () => { + expect(service.playerProfileLink("76561198000000001", "keith")).toBe( + `keith`, + ); + }); + + it("escapes the name and encodes the steam id", () => { + expect( + service.playerProfileLink('1">`), + ).toBe( + `` + + `<img src=x onerror="alert('1')">`, + ); + }); + }); + + describe("notifyMatchPlayersOfSanction", () => { + it.each(["mute", "gag", "silence"])( + "keeps a %s between the player and staff", + async (type) => { + await service.notifyMatchPlayersOfSanction(sanction(type)); + + expect(postgres.query).not.toHaveBeenCalled(); + expect(notifyPlayers).not.toHaveBeenCalled(); + }, + ); + + it("tells recent team-mates about a ban", async () => { + await service.notifyMatchPlayersOfSanction(sanction("ban")); + + expect(notifyPlayers).toHaveBeenCalledTimes(1); + const [type, notification] = notifyPlayers.mock.calls[0]; + expect(type).toBe("PlayerSanctioned"); + expect(notification.steamIds).toEqual(["76561198000000002"]); + expect(notification.message).toContain( + `keith, was banned. (cheating)`, + ); + }); + }); +}); diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts index 97d71ece..d67dbb52 100644 --- a/src/notifications/notifications.service.ts +++ b/src/notifications/notifications.service.ts @@ -114,12 +114,11 @@ export class NotificationsService { .replace(/'/g, "'"); } - private static readonly SANCTION_VERBS: Record = { - ban: "banned", - mute: "muted", - gag: "gagged", - silence: "silenced", - }; + public playerProfileLink(steamId: string, name: string): string { + return `${NotificationsService.escapeHtml(name)}`; + } async notifyMatchPlayersOfSanction(sanction: { sanctionId: string; @@ -127,6 +126,12 @@ export class NotificationsService { type: string; reason?: string | null; }): Promise { + // A mute or gag is chat moderation, and "was muted" sent to six months of + // team-mates reads as a ban to every one of them. + if (sanction.type !== "ban") { + return; + } + const recipients = await this.postgres.query>( `SELECT DISTINCT other_p.steam_id::text AS steam_id FROM public.matches m @@ -154,19 +159,12 @@ export class NotificationsService { }); const name = players_by_pk?.name ?? `Player ${sanction.steamId}`; - const verb = - NotificationsService.SANCTION_VERBS[sanction.type] ?? "sanctioned"; - const safeName = NotificationsService.escapeHtml(name); - const profileUrl = `${this.appConfig.webDomain}/players/${encodeURIComponent( - sanction.steamId, - )}`; - const reasonSuffix = - sanction.type === "ban" && sanction.reason - ? ` (${NotificationsService.escapeHtml(sanction.reason)})` - : ""; + const reasonSuffix = sanction.reason + ? ` (${NotificationsService.escapeHtml(sanction.reason)})` + : ""; const message = `A player you recently played with, ` + - `${safeName}, was ${verb}.${reasonSuffix}`; + `${this.playerProfileLink(sanction.steamId, name)}, was banned.${reasonSuffix}`; // Through notifyPlayers rather than the raw insert this used to be. Six // months of team-mates is routinely hundreds of rows and the event trigger diff --git a/src/sockets/sockets.gateway.spec.ts b/src/sockets/sockets.gateway.spec.ts new file mode 100644 index 00000000..7f19d28c --- /dev/null +++ b/src/sockets/sockets.gateway.spec.ts @@ -0,0 +1,72 @@ +import { SocketsGateway } from "./sockets.gateway"; + +describe("SocketsGateway ping", () => { + let gateway: SocketsGateway; + let sockets: { updateClient: jest.Mock }; + + const OPEN = 1; + const CLOSED = 3; + + const client = (overrides: Record = {}) => + ({ + id: "client-1", + user: undefined, + readyState: OPEN, + OPEN, + send: jest.fn(), + ...overrides, + }) as any; + + beforeEach(() => { + sockets = { updateClient: jest.fn().mockResolvedValue(undefined) }; + gateway = new SocketsGateway(sockets as any); + }); + + it("answers an anonymous client without touching its presence", async () => { + const anonymous = client(); + + await gateway.handleMessage(anonymous); + + expect(anonymous.send).toHaveBeenCalledWith( + JSON.stringify({ event: "pong" }), + ); + expect(sockets.updateClient).not.toHaveBeenCalled(); + }); + + it("answers a signed-in client and refreshes its presence", async () => { + const signedIn = client({ user: { steam_id: "76561198000000001" } }); + + await gateway.handleMessage(signedIn); + + expect(signedIn.send).toHaveBeenCalledWith( + JSON.stringify({ event: "pong" }), + ); + expect(sockets.updateClient).toHaveBeenCalledWith( + "76561198000000001", + "client-1", + ); + }); + + it("sends the pong before presence is written", async () => { + let sentBeforeUpdate = false; + const signedIn = client({ user: { steam_id: "76561198000000001" } }); + sockets.updateClient.mockImplementation(async () => { + sentBeforeUpdate = signedIn.send.mock.calls.length > 0; + }); + + await gateway.handleMessage(signedIn); + + expect(sentBeforeUpdate).toBe(true); + }); + + it("sends nothing to a socket that is no longer open", async () => { + const closing = client({ + readyState: CLOSED, + user: { steam_id: "76561198000000001" }, + }); + + await gateway.handleMessage(closing); + + expect(closing.send).not.toHaveBeenCalled(); + }); +}); diff --git a/src/sockets/sockets.gateway.ts b/src/sockets/sockets.gateway.ts index b10266f2..5a2a029d 100644 --- a/src/sockets/sockets.gateway.ts +++ b/src/sockets/sockets.gateway.ts @@ -17,6 +17,12 @@ export class SocketsGateway implements OnGatewayConnection { @SubscribeMessage("ping") public async handleMessage(client: FiveStackWebSocketClient): Promise { + // Sent here rather than returned: WsAdapter only replies once the handler + // resolves, which would hold the pong behind the redis writes below. + if (client.readyState === client.OPEN) { + client.send(JSON.stringify({ event: "pong" })); + } + if (!client.user) { return; } diff --git a/src/system/system.controller.spec.ts b/src/system/system.controller.spec.ts index a5aed324..371627aa 100644 --- a/src/system/system.controller.spec.ts +++ b/src/system/system.controller.spec.ts @@ -6,7 +6,11 @@ import { SystemController } from "./system.controller"; describe("SystemController names", () => { let controller: SystemController; let hasura: { query: jest.Mock; mutation: jest.Mock }; - let notifications: { send: jest.Mock; notifyPlayers: jest.Mock }; + let notifications: { + send: jest.Mock; + notifyPlayers: jest.Mock; + playerProfileLink: jest.Mock; + }; let player: { name: string; name_registered: boolean } | null; const user = (steamId: string, role: string | null = "user") => @@ -25,7 +29,14 @@ describe("SystemController names", () => { mutation: jest.fn(async () => ({})), }; - notifications = { send: jest.fn(), notifyPlayers: jest.fn() }; + notifications = { + send: jest.fn(), + notifyPlayers: jest.fn(), + playerProfileLink: jest.fn( + (steamId: string, name: string) => + `${name}`, + ), + }; controller = new SystemController( {} as any, @@ -105,6 +116,13 @@ describe("SystemController names", () => { expect(notifications.send).toHaveBeenCalled(); const [, notification] = notifications.send.mock.calls[0]; expect(notification.entity_id).toBe("76561198000000001"); + expect(notifications.playerProfileLink).toHaveBeenCalledWith( + "76561198000000001", + "current", + ); + expect(notification.message).toContain( + 'current', + ); }); it("ignores a steam id the caller does not own", async () => { @@ -118,6 +136,14 @@ describe("SystemController names", () => { const [, notification] = notifications.send.mock.calls[0]; expect(notification.entity_id).toBe("76561198000000001"); + expect(notifications.playerProfileLink).toHaveBeenCalledWith( + "76561198000000001", + "current", + ); + expect(notifications.playerProfileLink).not.toHaveBeenCalledWith( + "76561198000000002", + expect.anything(), + ); }); it("lets an administrator file a request for another player", async () => { @@ -129,6 +155,10 @@ describe("SystemController names", () => { const [, notification] = notifications.send.mock.calls[0]; expect(notification.entity_id).toBe("76561198000000002"); + expect(notifications.playerProfileLink).toHaveBeenCalledWith( + "76561198000000002", + "current", + ); }); it("rejects a blank name", async () => { diff --git a/src/system/system.controller.ts b/src/system/system.controller.ts index 8b27bc6e..e02b9cd0 100644 --- a/src/system/system.controller.ts +++ b/src/system/system.controller.ts @@ -313,7 +313,7 @@ export class SystemController { await this.notifications.send( "NameChangeRequest", { - message: `Player ${NotificationsService.escapeHtml(player.name)} has requested to change their name to ${NotificationsService.escapeHtml(name)}`, + message: `Player ${this.notifications.playerProfileLink(steamId, player.name)} has requested to change their name to ${NotificationsService.escapeHtml(name)}`, title: "Name Change Request", role: "administrator", entity_id: steamId, diff --git a/src/tournaments/tournaments.controller.spec.ts b/src/tournaments/tournaments.controller.spec.ts index ba80281e..47d13fe7 100644 --- a/src/tournaments/tournaments.controller.spec.ts +++ b/src/tournaments/tournaments.controller.spec.ts @@ -258,7 +258,7 @@ describe("TournamentsController registration and check-in actions", () => { tournament_id: "tournament-1", tournament_team_id: "team-1", }), - ).rejects.toThrow(/captain/i); + ).rejects.toThrow(/only the team captain or a team admin/i); }); }); diff --git a/src/tournaments/tournaments.controller.ts b/src/tournaments/tournaments.controller.ts index 1979b358..87a1c120 100644 --- a/src/tournaments/tournaments.controller.ts +++ b/src/tournaments/tournaments.controller.ts @@ -566,7 +566,9 @@ export class TournamentsController { } default: { if (!team.can_manage && !team.is_captain) { - throw Error("only the team captain can check this team in"); + throw Error( + "only the team captain or a team admin can check this team in", + ); } break; } From 95853a0ee505e14769536fa11f7406d7a75ea010 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 14:28:56 -0400 Subject: [PATCH 2/5] bug: name the support requester by their current name, not the session's --- src/matches/matches.controller.spec.ts | 51 +++++++++++++++++--------- src/matches/matches.controller.ts | 36 +++++++++++------- 2 files changed, 56 insertions(+), 31 deletions(-) diff --git a/src/matches/matches.controller.spec.ts b/src/matches/matches.controller.spec.ts index ce035ce9..4ae94785 100644 --- a/src/matches/matches.controller.spec.ts +++ b/src/matches/matches.controller.spec.ts @@ -90,10 +90,19 @@ describe("MatchesController", () => { describe("callForOrganizer", () => { const matchId = "00000000-0000-0000-0000-000000000001"; - const callForOrganizer = (user: Record) => + const callForOrganizer = () => controller.callForOrganizer({ match_id: matchId, - user: { steam_id: "76561198000000001", ...user } as any, + user: { steam_id: "76561198000000001", name: "signed-in-as" } as any, + }); + + const respond = ( + match: Record, + player: { name: string } | null = { name: "keith" }, + ) => + hasura.query.mockResolvedValue({ + matches_by_pk: match, + players_by_pk: player, }); const sent = () => { @@ -102,13 +111,16 @@ describe("MatchesController", () => { }; beforeEach(() => { - hasura.query.mockResolvedValue({ - matches_by_pk: { is_in_lineup: true, requested_organizer: false }, - }); + respond({ is_in_lineup: true, requested_organizer: false }); }); it("names the requester without linking them", async () => { - await callForOrganizer({ name: "keith" }); + respond( + { is_in_lineup: true, requested_organizer: false }, + { name: "keith" }, + ); + + await callForOrganizer(); const notification = sent(); expect(notification.type).toBe("MatchSupport"); @@ -120,8 +132,17 @@ describe("MatchesController", () => { ); }); - it("falls back to the steam id when the requester has no name", async () => { - await callForOrganizer({ name: undefined }); + it("uses the current name rather than the one the session signed in with", async () => { + await callForOrganizer(); + + expect(sent().message).toContain("keith requested assistance"); + expect(sent().message).not.toContain("signed-in-as"); + }); + + it("falls back to the steam id when the player row is missing", async () => { + respond({ is_in_lineup: true, requested_organizer: false }, null); + + await callForOrganizer(); expect(sent().message).toContain( "76561198000000001 requested assistance", @@ -129,29 +150,25 @@ describe("MatchesController", () => { }); it("titles the notification without the old typo", async () => { - await callForOrganizer({ name: "keith" }); + await callForOrganizer(); expect(sent().title).toBe("Match Assistance Required"); expect(sent().message).not.toContain("Assistanced"); }); it("rejects someone who is not playing in the match", async () => { - hasura.query.mockResolvedValue({ - matches_by_pk: { is_in_lineup: false, requested_organizer: false }, - }); + respond({ is_in_lineup: false, requested_organizer: false }); - await expect(callForOrganizer({ name: "keith" })).rejects.toThrow( + await expect(callForOrganizer()).rejects.toThrow( "only players in this match can contact support", ); expect(notifications.send).not.toHaveBeenCalled(); }); it("does not ask twice while a request is still open", async () => { - hasura.query.mockResolvedValue({ - matches_by_pk: { is_in_lineup: true, requested_organizer: true }, - }); + respond({ is_in_lineup: true, requested_organizer: true }); - await expect(callForOrganizer({ name: "keith" })).resolves.toEqual({ + await expect(callForOrganizer()).resolves.toEqual({ success: true, }); expect(notifications.send).not.toHaveBeenCalled(); diff --git a/src/matches/matches.controller.ts b/src/matches/matches.controller.ts index e2fc7029..8896a78c 100644 --- a/src/matches/matches.controller.ts +++ b/src/matches/matches.controller.ts @@ -2518,18 +2518,25 @@ export class MatchesController { @HasuraAction() public async callForOrganizer(data: { user: User; match_id: string }) { - const { matches_by_pk: match } = await this.hasura.query( - { - matches_by_pk: { - __args: { - id: data.match_id, + const { matches_by_pk: match, players_by_pk: requester } = + await this.hasura.query( + { + matches_by_pk: { + __args: { + id: data.match_id, + }, + is_in_lineup: true, + requested_organizer: true, + }, + players_by_pk: { + __args: { + steam_id: data.user.steam_id, + }, + name: true, }, - is_in_lineup: true, - requested_organizer: true, }, - }, - data.user.steam_id, - ); + data.user.steam_id, + ); if (!match || match.requested_organizer) { return { @@ -2542,15 +2549,16 @@ export class MatchesController { } // The requester stays plain text: notificationUrl takes the first href as - // where the push lands, and that has to be the match. - const requester = NotificationsService.escapeHtml( - data.user.name ?? data.user.steam_id, + // where the push lands, and that has to be the match. The name is read from + // the row because the session keeps whatever it was at sign-in. + const requesterName = NotificationsService.escapeHtml( + requester?.name ?? data.user.steam_id, ); void this.notifications.send( "MatchSupport", { - message: `${requester} requested assistance in match ${data.match_id}`, + message: `${requesterName} requested assistance in match ${data.match_id}`, title: "Match Assistance Required", role: "match_organizer", entity_id: data.match_id, From 7f5513ecb40155ddb7367ebd35b7fc23b72e8030 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 15:32:24 -0400 Subject: [PATCH 3/5] feature: informational warning sanction - new `warning` sanction type: requires a reason, never expires (tbiu trigger nulls remove_sanction_date), never touches a server - getActiveServerSanctions only serves ban/mute/gag/silence to the plugin - unsanctionServerPlayer takes an optional sanction_id; a warning can only be removed by id - PlayerWarning notification to the warned player only (account push category, instant-unseen, off Discord, not mutable in the bell) - warnings are hidden from guests and other players, visible to the warned player and moderators; excluded from the search sanction count - generated types hand-patched for the new enum values and action arg --- generated/schema.graphql | 8 +- generated/schema.ts | 10 +- generated/types.ts | 3 + hasura/enums/notification-types.sql | 3 +- hasura/enums/sanction-types.sql | 3 +- hasura/metadata/actions.graphql | 1 + .../tables/public_player_sanctions.yaml | 44 +++- hasura/triggers/player_sanctions.sql | 15 ++ src/hasura/metadata-permissions.spec.ts | 58 +++++ .../jobs/SendSanctionNotifications.ts | 1 + .../notifications.service.spec.ts | 60 +++++- src/notifications/notifications.service.ts | 31 +++ .../preferences/notification-categories.ts | 8 +- .../push/notification-delivery.ts | 1 + .../push/push-notifications.service.spec.ts | 13 ++ .../utilities/notificationUrl.ts | 1 + src/sanctions/sanctions.controller.ts | 4 +- src/sanctions/sanctions.service.spec.ts | 202 ++++++++++++++++++ src/sanctions/sanctions.service.ts | 97 +++++++-- src/type-sense/type-sense.service.ts | 7 + test/notifications.spec.ts | 6 + test/sanctions-warning.spec.ts | 196 +++++++++++++++++ 22 files changed, 747 insertions(+), 25 deletions(-) create mode 100644 src/sanctions/sanctions.service.spec.ts create mode 100644 test/sanctions-warning.spec.ts diff --git a/generated/schema.graphql b/generated/schema.graphql index 6511583f..21dd0cab 100644 --- a/generated/schema.graphql +++ b/generated/schema.graphql @@ -13712,6 +13712,9 @@ enum e_notification_types_enum { """A player you recently played with received a sanction""" PlayerSanctioned + """A moderator issued you a warning""" + PlayerWarning + """A team matching your scrim alert is available""" ScrimAlertMatch @@ -15186,6 +15189,9 @@ enum e_sanction_types_enum { """Player muted and gagged""" silence + + """Informational note on the player's record; never enforced, never expires""" + warning } """ @@ -53053,7 +53059,7 @@ type mutation_root { uninstallGamePlugin(force: Boolean, slug: String!): SuccessOutput unlinkDiscord: SuccessOutput unlinkSteamMatchHistory: SuccessOutput - unsanctionServerPlayer(serverId: String, steam_id: String!, type: String!): SanctionResult! + unsanctionServerPlayer(sanction_id: uuid, serverId: String, steam_id: String!, type: String!): SanctionResult! """Owner-only patch for clip title / visibility / target_steam_id.""" updateClip(clip_id: uuid!, target_steam_id: String, title: String, visibility: String): SuccessOutput diff --git a/generated/schema.ts b/generated/schema.ts index 740138de..24caceb6 100644 --- a/generated/schema.ts +++ b/generated/schema.ts @@ -5662,7 +5662,7 @@ export interface e_notification_types_aggregate_fields { /** unique or primary key constraints on table "e_notification_types" */ export type e_notification_types_constraint = 'e_notification_types_pkey' -export type e_notification_types_enum = 'AwardGranted' | 'ChatMessage' | 'ClipReady' | 'DedicatedServerRconStatus' | 'DedicatedServerStatus' | 'DraftInvite' | 'EloRecompute' | 'EventReminder' | 'FormTeamSuggestion' | 'GameNodeStatus' | 'GameUpdate' | 'LeagueMatchUnscheduled' | 'LeagueProposalAccepted' | 'LeagueProposalDeclined' | 'LeagueProposalReceived' | 'LeagueRegistrationDecision' | 'LeagueRosterUndersized' | 'MatchAbandoned' | 'MatchChatMessage' | 'MatchImported' | 'MatchStatsReady' | 'MatchStatusChange' | 'MatchSupport' | 'NadeDriftScanFinished' | 'NadePracticeInvite' | 'NadePracticeReady' | 'NameChangeApproved' | 'NameChangeDenied' | 'NameChangeRequest' | 'NewsPublished' | 'PlayerReindex' | 'PlayerSanctioned' | 'ScrimAlertMatch' | 'ScrimMatchCanceled' | 'ScrimMatchScheduled' | 'ScrimRequestAccepted' | 'ScrimRequestCountered' | 'ScrimRequestDeclined' | 'ScrimRequestExpired' | 'ScrimRequestReceived' | 'ScrimTimeChanged' | 'SeasonEnded' | 'StorageScan' | 'TeamInvite' | 'TournamentCheckInClosing' | 'TournamentCheckInMissed' | 'TournamentCheckInOpen' | 'TournamentCreated' | 'TournamentInvite' | 'TournamentPartySignup' | 'TournamentReminder' | 'TournamentTeamInvite' | 'UtilityDriftScanFinished' | 'UtilityPracticeInvite' | 'UtilityPracticeReady' +export type e_notification_types_enum = 'AwardGranted' | 'ChatMessage' | 'ClipReady' | 'DedicatedServerRconStatus' | 'DedicatedServerStatus' | 'DraftInvite' | 'EloRecompute' | 'EventReminder' | 'FormTeamSuggestion' | 'GameNodeStatus' | 'GameUpdate' | 'LeagueMatchUnscheduled' | 'LeagueProposalAccepted' | 'LeagueProposalDeclined' | 'LeagueProposalReceived' | 'LeagueRegistrationDecision' | 'LeagueRosterUndersized' | 'MatchAbandoned' | 'MatchChatMessage' | 'MatchImported' | 'MatchStatsReady' | 'MatchStatusChange' | 'MatchSupport' | 'NadeDriftScanFinished' | 'NadePracticeInvite' | 'NadePracticeReady' | 'NameChangeApproved' | 'NameChangeDenied' | 'NameChangeRequest' | 'NewsPublished' | 'PlayerReindex' | 'PlayerSanctioned' | 'PlayerWarning' | 'ScrimAlertMatch' | 'ScrimMatchCanceled' | 'ScrimMatchScheduled' | 'ScrimRequestAccepted' | 'ScrimRequestCountered' | 'ScrimRequestDeclined' | 'ScrimRequestExpired' | 'ScrimRequestReceived' | 'ScrimTimeChanged' | 'SeasonEnded' | 'StorageScan' | 'TeamInvite' | 'TournamentCheckInClosing' | 'TournamentCheckInMissed' | 'TournamentCheckInOpen' | 'TournamentCreated' | 'TournamentInvite' | 'TournamentPartySignup' | 'TournamentReminder' | 'TournamentTeamInvite' | 'UtilityDriftScanFinished' | 'UtilityPracticeInvite' | 'UtilityPracticeReady' /** aggregate max on columns */ @@ -6209,7 +6209,7 @@ export interface e_sanction_types_aggregate_fields { /** unique or primary key constraints on table "e_sanction_types" */ export type e_sanction_types_constraint = 'e_sanction_types_pkey' -export type e_sanction_types_enum = 'ban' | 'gag' | 'mute' | 'silence' +export type e_sanction_types_enum = 'ban' | 'gag' | 'mute' | 'silence' | 'warning' /** aggregate max on columns */ @@ -75184,7 +75184,7 @@ export interface mutation_rootGenqlSelection{ uninstallGamePlugin?: (SuccessOutputGenqlSelection & { __args: {force?: (Scalars['Boolean'] | null), slug: Scalars['String']} }) unlinkDiscord?: SuccessOutputGenqlSelection unlinkSteamMatchHistory?: SuccessOutputGenqlSelection - unsanctionServerPlayer?: (SanctionResultGenqlSelection & { __args: {serverId?: (Scalars['String'] | null), steam_id: Scalars['String'], type: Scalars['String']} }) + unsanctionServerPlayer?: (SanctionResultGenqlSelection & { __args: {sanction_id?: (Scalars['uuid'] | null), serverId?: (Scalars['String'] | null), steam_id: Scalars['String'], type: Scalars['String']} }) /** Owner-only patch for clip title / visibility / target_steam_id. */ updateClip?: (SuccessOutputGenqlSelection & { __args: {clip_id: Scalars['uuid'], target_steam_id?: (Scalars['String'] | null), title?: (Scalars['String'] | null), visibility?: (Scalars['String'] | null)} }) updateCs?: (SuccessOutputGenqlSelection & { __args?: {game?: (Scalars['String'] | null), game_server_node_id?: (Scalars['uuid'] | null)} }) @@ -152215,6 +152215,7 @@ export const enumENotificationTypesEnum = { NewsPublished: 'NewsPublished' as const, PlayerReindex: 'PlayerReindex' as const, PlayerSanctioned: 'PlayerSanctioned' as const, + PlayerWarning: 'PlayerWarning' as const, ScrimAlertMatch: 'ScrimAlertMatch' as const, ScrimMatchCanceled: 'ScrimMatchCanceled' as const, ScrimMatchScheduled: 'ScrimMatchScheduled' as const, @@ -152382,7 +152383,8 @@ export const enumESanctionTypesEnum = { ban: 'ban' as const, gag: 'gag' as const, mute: 'mute' as const, - silence: 'silence' as const + silence: 'silence' as const, + warning: 'warning' as const } export const enumESanctionTypesSelectColumn = { diff --git a/generated/types.ts b/generated/types.ts index 4a5e9d06..5fb284a6 100644 --- a/generated/types.ts +++ b/generated/types.ts @@ -198564,6 +198564,9 @@ export default { "unsanctionServerPlayer": [ 71, { + "sanction_id": [ + 6739 + ], "serverId": [ 85 ], diff --git a/hasura/enums/notification-types.sql b/hasura/enums/notification-types.sql index 4cd7a5b0..a4e972eb 100644 --- a/hasura/enums/notification-types.sql +++ b/hasura/enums/notification-types.sql @@ -49,6 +49,7 @@ INSERT INTO e_notification_types ("value", "description") VALUES ('UtilityPracticeInvite', 'You were invited to a utility practice session'), ('UtilityPracticeReady', 'Your utility practice server is ready'), ('UtilityDriftScanFinished', 'A utility drift scan finished'), - ('TournamentPartySignup', 'Your lobby was signed up for a tournament as a free agent party') + ('TournamentPartySignup', 'Your lobby was signed up for a tournament as a free agent party'), + ('PlayerWarning', 'A moderator issued you a warning') ON CONFLICT("value") DO UPDATE SET "description" = EXCLUDED."description"; diff --git a/hasura/enums/sanction-types.sql b/hasura/enums/sanction-types.sql index a6fcb5cd..bf6877ce 100644 --- a/hasura/enums/sanction-types.sql +++ b/hasura/enums/sanction-types.sql @@ -2,5 +2,6 @@ insert into e_sanction_types ("value", "description") values ('ban', 'Player is not able to participate in any activity'), ('mute', 'Player cannot use voice chat in game'), ('gag', 'Player cannot use text chat in game'), - ('silence', 'Player muted and gagged') + ('silence', 'Player muted and gagged'), + ('warning', 'Informational note on the player''s record; never enforced, never expires') on conflict(value) do update set "description" = EXCLUDED."description" diff --git a/hasura/metadata/actions.graphql b/hasura/metadata/actions.graphql index 708f4074..5295b748 100644 --- a/hasura/metadata/actions.graphql +++ b/hasura/metadata/actions.graphql @@ -810,6 +810,7 @@ type Mutation { serverId: String steam_id: String! type: String! + sanction_id: uuid ): SanctionResult! } diff --git a/hasura/metadata/databases/default/tables/public_player_sanctions.yaml b/hasura/metadata/databases/default/tables/public_player_sanctions.yaml index bf81d2c1..9ecb52ac 100644 --- a/hasura/metadata/databases/default/tables/public_player_sanctions.yaml +++ b/hasura/metadata/databases/default/tables/public_player_sanctions.yaml @@ -13,6 +13,23 @@ object_relationships: foreign_key_constraint_on: sanctioned_by_steam_id select_permissions: - role: guest + permission: + columns: + - player_steam_id + - sanctioned_by_steam_id + - reason + - type + - created_at + - remove_sanction_date + - id + filter: + _and: + - deleted_at: + _is_null: true + - type: + _neq: warning + comment: "" + - role: moderator permission: columns: - player_steam_id @@ -26,14 +43,37 @@ select_permissions: deleted_at: _is_null: true comment: "" + - role: user + permission: + columns: + - player_steam_id + - sanctioned_by_steam_id + - reason + - type + - created_at + - remove_sanction_date + - id + filter: + _and: + - deleted_at: + _is_null: true + - _or: + - type: + _neq: warning + - player_steam_id: + _eq: X-Hasura-User-Id + comment: "" update_permissions: - role: moderator permission: columns: - remove_sanction_date filter: - deleted_at: - _is_null: true + _and: + - deleted_at: + _is_null: true + - type: + _neq: warning check: null comment: "" event_triggers: diff --git a/hasura/triggers/player_sanctions.sql b/hasura/triggers/player_sanctions.sql index a7de120c..2f75f78b 100644 --- a/hasura/triggers/player_sanctions.sql +++ b/hasura/triggers/player_sanctions.sql @@ -17,3 +17,18 @@ $$; DROP TRIGGER IF EXISTS tau_player_sanctions ON public.player_sanctions; CREATE TRIGGER tau_player_sanctions AFTER UPDATE ON public.player_sanctions FOR EACH ROW EXECUTE FUNCTION public.tau_player_sanctions(); + +CREATE OR REPLACE FUNCTION public.tbiu_player_sanctions() RETURNS TRIGGER + LANGUAGE plpgsql + AS $$ +BEGIN + IF NEW.type = 'warning' THEN + NEW.remove_sanction_date := NULL; + END IF; + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS tbiu_player_sanctions ON public.player_sanctions; +CREATE TRIGGER tbiu_player_sanctions BEFORE INSERT OR UPDATE ON public.player_sanctions FOR EACH ROW EXECUTE FUNCTION public.tbiu_player_sanctions(); diff --git a/src/hasura/metadata-permissions.spec.ts b/src/hasura/metadata-permissions.spec.ts index dd38315f..d52bdb4e 100644 --- a/src/hasura/metadata-permissions.spec.ts +++ b/src/hasura/metadata-permissions.spec.ts @@ -149,6 +149,64 @@ describe("hasura table metadata", () => { ).toEqual(["administrator"]); }); + // A warning is a private note between the player and staff; every other + // sanction stays on the public record. + describe("player_sanctions warnings", () => { + const sanctions = () => + tables.find(({ file }) => file === "public_player_sanctions.yaml") + ?.metadata; + + const selectFilter = (role: string) => + blocksByRole(sanctions(), "select_permissions").get(role)?.filter; + + it("hides warnings from guests", () => { + expect(selectFilter("guest")).toEqual({ + _and: [ + { deleted_at: { _is_null: true } }, + { type: { _neq: "warning" } }, + ], + }); + }); + + it("shows a user only their own warnings", () => { + expect(selectFilter("user")).toEqual({ + _and: [ + { deleted_at: { _is_null: true } }, + { + _or: [ + { type: { _neq: "warning" } }, + { player_steam_id: { _eq: "X-Hasura-User-Id" } }, + ], + }, + ], + }); + }); + + it("shows moderators every warning", () => { + expect(selectFilter("moderator")).toEqual({ + deleted_at: { _is_null: true }, + }); + }); + + it("only defines select for the roles that change what is visible", () => { + expect( + [...blocksByRole(sanctions(), "select_permissions").keys()].sort(), + ).toEqual(["guest", "moderator", "user"]); + }); + + it("never lets a moderator give a warning an end date", () => { + expect( + blocksByRole(sanctions(), "update_permissions").get("moderator") + ?.filter, + ).toEqual({ + _and: [ + { deleted_at: { _is_null: true } }, + { type: { _neq: "warning" } }, + ], + }); + }); + }); + it("never lists the same name as both a column and a computed field", () => { const problems: Array = []; diff --git a/src/notifications/jobs/SendSanctionNotifications.ts b/src/notifications/jobs/SendSanctionNotifications.ts index acb7aa81..44946af2 100644 --- a/src/notifications/jobs/SendSanctionNotifications.ts +++ b/src/notifications/jobs/SendSanctionNotifications.ts @@ -19,6 +19,7 @@ export class SendSanctionNotifications extends WorkerHost { }>, ): Promise { await this.notifications.notifyBannedPlayer(job.data); + await this.notifications.notifyWarnedPlayer(job.data); await this.notifications.notifyMatchPlayersOfSanction(job.data); await this.notifications.notifyAdminsOfBan(job.data); } diff --git a/src/notifications/notifications.service.spec.ts b/src/notifications/notifications.service.spec.ts index e4a4256b..504c6c7e 100644 --- a/src/notifications/notifications.service.spec.ts +++ b/src/notifications/notifications.service.spec.ts @@ -50,6 +50,7 @@ describe("discord routing", () => { "ChatMessage", "MatchChatMessage", "PlayerSanctioned", + "PlayerWarning", "MatchImported", ])("keeps %s off discord", (type) => { expect(NotificationsService.relaysToDiscord(type)).toBe(false); @@ -230,7 +231,7 @@ describe("NotificationsService", () => { }); describe("notifyMatchPlayersOfSanction", () => { - it.each(["mute", "gag", "silence"])( + it.each(["mute", "gag", "silence", "warning"])( "keeps a %s between the player and staff", async (type) => { await service.notifyMatchPlayersOfSanction(sanction(type)); @@ -252,4 +253,61 @@ describe("NotificationsService", () => { ); }); }); + + describe("warnings", () => { + const signedIn = () => + hasura.query.mockResolvedValue({ + players_by_pk: { last_sign_in_at: "2026-09-01T00:00:00.000Z" }, + }); + + it("tells only the warned player, with the reason escaped", async () => { + signedIn(); + + await service.notifyWarnedPlayer({ + ...sanction("warning"), + reason: `spam & "toxic"`, + }); + + expect(notifyPlayers).toHaveBeenCalledTimes(1); + const [type, notification] = notifyPlayers.mock.calls[0]; + expect(type).toBe("PlayerWarning"); + expect(notification).toEqual({ + title: "You received a warning", + message: "<b>spam</b> & "toxic"", + role: "user", + entity_id: "76561198000000001", + steamIds: ["76561198000000001"], + }); + }); + + it("skips a player who has never signed in", async () => { + hasura.query.mockResolvedValue({ + players_by_pk: { last_sign_in_at: null }, + }); + + await service.notifyWarnedPlayer(sanction("warning")); + + expect(notifyPlayers).not.toHaveBeenCalled(); + }); + + it.each(["ban", "mute", "gag", "silence"])("ignores a %s", async (type) => { + signedIn(); + + await service.notifyWarnedPlayer(sanction(type)); + + expect(hasura.query).not.toHaveBeenCalled(); + expect(notifyPlayers).not.toHaveBeenCalled(); + }); + + it("never reaches admins or the banned-player notice", async () => { + signedIn(); + + await service.notifyBannedPlayer(sanction("warning")); + await service.notifyAdminsOfBan(sanction("warning")); + + expect(hasura.query).not.toHaveBeenCalled(); + expect(hasura.mutation).not.toHaveBeenCalled(); + expect(postgres.query).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts index d67dbb52..379b0f0d 100644 --- a/src/notifications/notifications.service.ts +++ b/src/notifications/notifications.service.ts @@ -281,6 +281,37 @@ export class NotificationsService { this.logger.log(`notified banned player ${sanction.steamId}`); } + async notifyWarnedPlayer(sanction: { + sanctionId: string; + steamId: string; + type: string; + reason?: string | null; + }): Promise { + if (sanction.type !== "warning") { + return; + } + + const { players_by_pk } = await this.hasura.query({ + players_by_pk: { + __args: { steam_id: sanction.steamId }, + last_sign_in_at: true, + }, + }); + if (!players_by_pk?.last_sign_in_at) { + return; + } + + await this.notifyPlayers("PlayerWarning", { + title: "You received a warning", + message: NotificationsService.escapeHtml(sanction.reason), + role: "user", + entity_id: sanction.steamId, + steamIds: [sanction.steamId], + }); + + this.logger.log(`notified warned player ${sanction.steamId}`); + } + async send( type: e_notification_types_enum, notification: { diff --git a/src/notifications/preferences/notification-categories.ts b/src/notifications/preferences/notification-categories.ts index 185fdc95..94530c79 100644 --- a/src/notifications/preferences/notification-categories.ts +++ b/src/notifications/preferences/notification-categories.ts @@ -61,7 +61,13 @@ export const PUSH_CATEGORIES: Record = { "DraftInvite", ], utility: ["UtilityPracticeInvite", "UtilityPracticeReady"], - account: ["NameChangeApproved", "NameChangeDenied", "PlayerSanctioned", "AwardGranted"], + account: [ + "NameChangeApproved", + "NameChangeDenied", + "PlayerSanctioned", + "PlayerWarning", + "AwardGranted", + ], news: ["NewsPublished"], staff_moderation: ["MatchSupport", "MatchAbandoned", "NameChangeRequest"], staff_infrastructure: [ diff --git a/src/notifications/push/notification-delivery.ts b/src/notifications/push/notification-delivery.ts index 9d78f705..ebca4588 100644 --- a/src/notifications/push/notification-delivery.ts +++ b/src/notifications/push/notification-delivery.ts @@ -48,6 +48,7 @@ const DELIVERY_POLICIES: Record = { "NameChangeApproved", "NameChangeDenied", "PlayerSanctioned", + "PlayerWarning", "TournamentReminder", "TournamentCheckInOpen", "TournamentCheckInClosing", diff --git a/src/notifications/push/push-notifications.service.spec.ts b/src/notifications/push/push-notifications.service.spec.ts index 387efbe9..c2e7e66f 100644 --- a/src/notifications/push/push-notifications.service.spec.ts +++ b/src/notifications/push/push-notifications.service.spec.ts @@ -1136,6 +1136,19 @@ describe("notificationUrl", () => { ).toBe("/matches/m-1"); }); + it("lands a warning on the warned player's profile, whatever the reason says", () => { + expect( + notificationUrl( + { + type: "PlayerWarning", + message: "<a href="/elsewhere">read</a>", + entity_id: "76561198000000001", + }, + webDomain, + ), + ).toBe("/players/76561198000000001"); + }); + it("strips the reminder window off a tournament entity id", () => { expect( notificationUrl( diff --git a/src/notifications/utilities/notificationUrl.ts b/src/notifications/utilities/notificationUrl.ts index 663addb8..39489078 100644 --- a/src/notifications/utilities/notificationUrl.ts +++ b/src/notifications/utilities/notificationUrl.ts @@ -40,6 +40,7 @@ const PATH_BY_TYPE: Record string> = { NameChangeApproved: () => `/settings`, NameChangeDenied: () => `/settings`, NameChangeRequest: (id) => `/players/${id}`, + PlayerWarning: (id) => `/players/${id}`, // A league notification is keyed by a bracket or a team's season entry, // neither of which is addressable on its own -- the season id that would // build /league/seasons/:id is not on the row. diff --git a/src/sanctions/sanctions.controller.ts b/src/sanctions/sanctions.controller.ts index 3ef51eb7..dbff66eb 100644 --- a/src/sanctions/sanctions.controller.ts +++ b/src/sanctions/sanctions.controller.ts @@ -45,9 +45,10 @@ export class SanctionsController { serverId?: string | null; steam_id: string; type: SanctionType; + sanction_id?: string | null; user: User; }) { - const { serverId, steam_id, type, user } = data; + const { serverId, steam_id, type, sanction_id, user } = data; if (!user || !isRoleAbove(user.role, "moderator")) { throw Error("you are not allowed to remove sanctions"); @@ -57,6 +58,7 @@ export class SanctionsController { serverId, steamId: steam_id, type, + sanctionId: sanction_id, }); } diff --git a/src/sanctions/sanctions.service.spec.ts b/src/sanctions/sanctions.service.spec.ts new file mode 100644 index 00000000..3533d5c7 --- /dev/null +++ b/src/sanctions/sanctions.service.spec.ts @@ -0,0 +1,202 @@ +import { SanctionsService } from "./sanctions.service"; + +describe("SanctionsService", () => { + let service: SanctionsService; + let hasura: { query: jest.Mock; mutation: jest.Mock }; + let postgres: { query: jest.Mock }; + let rcon: { send: jest.Mock }; + let rconService: { connect: jest.Mock; disconnect: jest.Mock }; + let dedicatedServers: { + getServerPlayerList: jest.Mock; + resolveServerUserId: jest.Mock; + }; + + const steamId = "76561198000000001"; + const moderator = "76561198000000009"; + + const insertedSanction = () => + hasura.mutation.mock.calls + .map(([mutation]) => mutation.insert_player_sanctions_one) + .find(Boolean)?.__args.object; + + beforeEach(() => { + hasura = { + query: jest.fn().mockResolvedValue({ matches: [{ id: "match-1" }] }), + mutation: jest.fn(async (mutation: any) => + mutation.insert_player_sanctions_one + ? { insert_player_sanctions_one: { id: "sanction-1" } } + : {}, + ), + }; + postgres = { query: jest.fn().mockResolvedValue([]) }; + rcon = { send: jest.fn().mockResolvedValue("") }; + rconService = { + connect: jest.fn().mockResolvedValue(rcon), + disconnect: jest.fn().mockResolvedValue(undefined), + }; + dedicatedServers = { + getServerPlayerList: jest + .fn() + .mockResolvedValue([{ steam_id: steamId, name: "keith", userid: "4" }]), + resolveServerUserId: jest.fn(), + }; + + service = new SanctionsService( + { log: jest.fn(), warn: jest.fn(), error: jest.fn() } as any, + hasura as any, + postgres as any, + rconService as any, + dedicatedServers as any, + ); + }); + + describe("warnings", () => { + it.each([undefined, null, "", " "])( + "refuses a warning without a reason (%p)", + async (reason) => { + await expect( + service.sanctionServerPlayer({ + steamId, + type: "warning", + reason, + sanctionedBySteamId: moderator, + }), + ).rejects.toThrow("a reason is required for a warning"); + + expect(insertedSanction()).toBeUndefined(); + }, + ); + + it("saves the trimmed reason and never sets an end date", async () => { + const result = await service.sanctionServerPlayer({ + steamId, + type: "warning", + reason: " toxic in voice ", + duration: 60_000, + sanctionedBySteamId: moderator, + }); + + expect(insertedSanction()).toEqual({ + type: "warning", + player_steam_id: steamId, + sanctioned_by_steam_id: moderator, + reason: "toxic in voice", + remove_sanction_date: null, + }); + expect(result).toEqual({ + id: "sanction-1", + enforced: false, + message: "warning saved", + }); + }); + + it("never touches the server, even when one is named", async () => { + await service.sanctionServerPlayer({ + serverId: "server-1", + steamId, + type: "warning", + reason: "toxic in voice", + sanctionedBySteamId: moderator, + }); + + expect(dedicatedServers.getServerPlayerList).not.toHaveBeenCalled(); + expect(rconService.connect).not.toHaveBeenCalled(); + expect(rcon.send).not.toHaveBeenCalled(); + }); + + it("still syncs the server for an enforced sanction", async () => { + await service.sanctionServerPlayer({ + serverId: "server-1", + steamId, + type: "ban", + reason: "cheating", + sanctionedBySteamId: moderator, + }); + + expect(rconService.connect).toHaveBeenCalledWith("server-1"); + expect(rcon.send).toHaveBeenCalledWith("kickid 4 Banned"); + expect(rcon.send).toHaveBeenCalledWith("get_match"); + }); + }); + + describe("getActiveServerSanctions", () => { + it("only reads the types a server enforces", async () => { + await service.getActiveServerSanctions("server-1"); + + const [sql, params] = postgres.query.mock.calls[0]; + expect(sql).toContain("type = ANY($1::text[])"); + expect(params).toEqual([["ban", "mute", "gag", "silence"]]); + expect(params[0]).not.toContain("warning"); + }); + + it("maps the enforced types onto the plugin flags", async () => { + postgres.query.mockResolvedValueOnce([ + { player_steam_id: "1", type: "ban" }, + { player_steam_id: "2", type: "silence" }, + { player_steam_id: "3", type: "mute" }, + ]); + + expect(await service.getActiveServerSanctions("server-1")).toEqual([ + { steam_id: "1", is_banned: true, is_muted: false, is_gagged: false }, + { steam_id: "2", is_banned: false, is_muted: true, is_gagged: true }, + { steam_id: "3", is_banned: false, is_muted: true, is_gagged: false }, + ]); + }); + }); + + describe("unsanctionServerPlayer", () => { + it("removes only the named row when given a sanction id", async () => { + postgres.query.mockResolvedValueOnce([{ id: "sanction-7" }]); + + const result = await service.unsanctionServerPlayer({ + steamId, + type: "warning", + sanctionId: "sanction-7", + }); + + expect(postgres.query).toHaveBeenCalledTimes(1); + const [sql, params] = postgres.query.mock.calls[0]; + expect(sql).toContain("WHERE id = $1::uuid"); + expect(sql).toContain("AND player_steam_id = $2::bigint"); + expect(sql).toContain("AND deleted_at IS NULL"); + expect(params).toEqual(["sanction-7", steamId, "warning"]); + expect(result).toEqual({ + id: "sanction-7", + enforced: false, + message: "warning removed", + }); + }); + + it("refuses to clear warnings by type alone", async () => { + await expect( + service.unsanctionServerPlayer({ steamId, type: "warning" }), + ).rejects.toThrow("a warning is removed by its sanction id"); + + expect(postgres.query).not.toHaveBeenCalled(); + }); + + it("never syncs the server when a warning is removed", async () => { + await service.unsanctionServerPlayer({ + serverId: "server-1", + steamId, + type: "warning", + sanctionId: "sanction-7", + }); + + expect(rconService.connect).not.toHaveBeenCalled(); + }); + + it("still clears an enforced type by type and syncs the server", async () => { + await service.unsanctionServerPlayer({ + serverId: "server-1", + steamId, + type: "mute", + }); + + const [sql, params] = postgres.query.mock.calls[0]; + expect(sql).not.toContain("WHERE id ="); + expect(params).toEqual([steamId, "mute"]); + expect(rcon.send).toHaveBeenCalledWith("get_match"); + }); + }); +}); diff --git a/src/sanctions/sanctions.service.ts b/src/sanctions/sanctions.service.ts index d92af920..c6049b42 100644 --- a/src/sanctions/sanctions.service.ts +++ b/src/sanctions/sanctions.service.ts @@ -4,7 +4,7 @@ import { PostgresService } from "src/postgres/postgres.service"; import { RconService } from "src/rcon/rcon.service"; import { DedicatedServersService } from "src/dedicated-servers/dedicated-servers.service"; -export type SanctionType = "ban" | "mute" | "gag" | "silence"; +export type SanctionType = "ban" | "mute" | "gag" | "silence" | "warning"; @Injectable() export class SanctionsService { @@ -21,6 +21,14 @@ export class SanctionsService { "mute", "gag", "silence", + "warning", + ]; + + public static readonly SERVER_ENFORCED_TYPES: SanctionType[] = [ + "ban", + "mute", + "gag", + "silence", ]; public async getActiveServerSanctions(serverId: string): Promise< @@ -37,7 +45,9 @@ export class SanctionsService { `SELECT player_steam_id::text AS player_steam_id, type FROM public.player_sanctions WHERE deleted_at IS NULL + AND type = ANY($1::text[]) AND (remove_sanction_date IS NULL OR remove_sanction_date > now())`, + [SanctionsService.SERVER_ENFORCED_TYPES], ); const byPlayer: Record< @@ -84,6 +94,10 @@ export class SanctionsService { throw Error(`invalid sanction type ${type}`); } + if (type === "warning") { + return await this.warnPlayer(steamId, reason, sanctionedBySteamId); + } + let onServer: | { steam_id: string; name: string; userid: string | null } | undefined; @@ -139,33 +153,56 @@ export class SanctionsService { serverId?: string | null; steamId: string; type: SanctionType; + sanctionId?: string | null; }): Promise<{ id: string | null; enforced: boolean; message: string }> { - const { serverId, steamId, type } = params; + const { serverId, steamId, type, sanctionId } = params; if (!SanctionsService.SANCTION_TYPES.includes(type)) { throw Error(`invalid sanction type ${type}`); } - await this.postgres.query( - `UPDATE public.player_sanctions - SET deleted_at = now() - WHERE player_steam_id = $1::bigint - AND type = $2 - AND deleted_at IS NULL`, - [steamId, type], - ); + // Removal by type clears every active row of that type, which for warnings + // would wipe the player's whole record to retract one of them. + if (type === "warning" && !sanctionId) { + throw Error("a warning is removed by its sanction id"); + } + + let removedId: string | null = null; + + if (sanctionId) { + const removed = await this.postgres.query>( + `UPDATE public.player_sanctions + SET deleted_at = now() + WHERE id = $1::uuid + AND player_steam_id = $2::bigint + AND type = $3 + AND deleted_at IS NULL + RETURNING id`, + [sanctionId, steamId, type], + ); + removedId = removed.at(0)?.id ?? null; + } else { + await this.postgres.query( + `UPDATE public.player_sanctions + SET deleted_at = now() + WHERE player_steam_id = $1::bigint + AND type = $2 + AND deleted_at IS NULL`, + [steamId, type], + ); + } let enforced = false; - let message = "sanction removed"; + let message = type === "warning" ? "warning removed" : "sanction removed"; - if (serverId) { + if (serverId && SanctionsService.SERVER_ENFORCED_TYPES.includes(type)) { const result = await this.syncServer(serverId, null); enforced = result.enforced; message = result.message; } return { - id: null, + id: removedId, enforced, message, }; @@ -209,6 +246,40 @@ export class SanctionsService { } } + private async warnPlayer( + steamId: string, + reason: string | null | undefined, + sanctionedBySteamId: string, + ): Promise<{ id: string | null; enforced: boolean; message: string }> { + const trimmedReason = reason?.trim(); + if (!trimmedReason) { + throw Error("a reason is required for a warning"); + } + + await this.ensurePlayer(steamId); + + const { insert_player_sanctions_one } = await this.hasura.mutation({ + insert_player_sanctions_one: { + __args: { + object: { + type: "warning", + player_steam_id: steamId, + sanctioned_by_steam_id: sanctionedBySteamId, + reason: trimmedReason, + remove_sanction_date: null, + }, + }, + id: true, + }, + }); + + return { + id: insert_player_sanctions_one?.id ?? null, + enforced: false, + message: "warning saved", + }; + } + private async ensurePlayer(steamId: string, name?: string): Promise { await this.hasura.mutation({ insert_players: { diff --git a/src/type-sense/type-sense.service.ts b/src/type-sense/type-sense.service.ts index d423eba9..02544c4b 100644 --- a/src/type-sense/type-sense.service.ts +++ b/src/type-sense/type-sense.service.ts @@ -529,6 +529,13 @@ export class TypeSenseService { deaths: true, }, sanctions_aggregate: { + __args: { + where: { + type: { + _neq: "warning", + }, + }, + }, aggregate: { count: true, }, diff --git a/test/notifications.spec.ts b/test/notifications.spec.ts index 9efee8c5..f0b363e2 100644 --- a/test/notifications.spec.ts +++ b/test/notifications.spec.ts @@ -404,6 +404,12 @@ describe("notifications (SQL-driven)", () => { expect(posted).toEqual([]); }); + it("keeps a warning between the player and staff", async () => { + await notify("PlayerWarning", "toxic in voice"); + + expect(posted).toEqual([]); + }); + it("still relays the types that are meant for it", async () => { // Guards the test itself: if the webhook never fired for any type, every // assertion above would pass for the wrong reason. diff --git a/test/sanctions-warning.spec.ts b/test/sanctions-warning.spec.ts new file mode 100644 index 00000000..c6d6f964 --- /dev/null +++ b/test/sanctions-warning.spec.ts @@ -0,0 +1,196 @@ +import { PostgresService } from "./../src/postgres/postgres.service"; +import { SanctionsService } from "./../src/sanctions/sanctions.service"; +import { Fixtures } from "./utils/fixtures"; +import { bootMigratedDb, SqlTestDb } from "./utils/sql-test-db"; + +// A warning is a note on the player's record: nothing that enforces a sanction +// may read it as one, and it never gets an end date. +describe("warning sanctions (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let fx: Fixtures; + + beforeAll(async () => { + db = await bootMigratedDb("SanctionsWarningTest"); + postgres = db.postgres; + fx = new Fixtures(postgres, 76561196200000000n); + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + beforeEach(async () => { + await postgres.query("DELETE FROM player_sanctions"); + await postgres.query("DELETE FROM players"); + }); + + const sanction = async ( + steamId: string, + type: string, + removeSanctionDate: string | null = null, + ) => { + const [row] = await postgres.query< + Array<{ id: string; remove_sanction_date: Date | null }> + >( + `INSERT INTO player_sanctions + (player_steam_id, type, reason, remove_sanction_date) + VALUES ($1::bigint, $2, 'reason', $3::timestamptz) + RETURNING id, remove_sanction_date`, + [steamId, type, removeSanctionDate], + ); + return row; + }; + + const flags = async (steamId: string) => { + const [row] = await postgres.query< + Array<{ + is_banned: boolean; + is_muted: boolean; + is_gagged: boolean; + is_admin_sanctioned: boolean; + banned_until: Date | null; + }> + >( + `SELECT public.is_banned(p) AS is_banned, + public.is_muted(p) AS is_muted, + public.is_gagged(p) AS is_gagged, + public.is_admin_sanctioned(p) AS is_admin_sanctioned, + public.banned_until(p) AS banned_until + FROM players p + WHERE p.steam_id = $1::bigint`, + [steamId], + ); + return row; + }; + + const service = () => + new SanctionsService( + { log: jest.fn(), warn: jest.fn(), error: jest.fn() } as any, + {} as any, + postgres, + {} as any, + {} as any, + ); + + it("enforces nothing on a player whose only sanction is a warning", async () => { + const steamId = await fx.player(); + const moderator = await fx.player(); + await postgres.query( + `INSERT INTO player_sanctions + (player_steam_id, type, reason, sanctioned_by_steam_id) + VALUES ($1::bigint, 'warning', 'toxic', $2::bigint)`, + [steamId, moderator], + ); + + expect(await flags(steamId)).toEqual({ + is_banned: false, + is_muted: false, + is_gagged: false, + is_admin_sanctioned: false, + banned_until: null, + }); + }); + + it("still enforces the real sanctions next to a warning", async () => { + const steamId = await fx.player(); + await sanction(steamId, "warning"); + await sanction(steamId, "silence"); + + expect(await flags(steamId)).toMatchObject({ + is_banned: false, + is_muted: true, + is_gagged: true, + }); + }); + + it("drops an end date given to a warning on insert", async () => { + const steamId = await fx.player(); + const row = await sanction( + steamId, + "warning", + new Date(Date.now() + 86_400_000).toISOString(), + ); + + expect(row.remove_sanction_date).toBeNull(); + }); + + it("drops an end date given to a warning on update", async () => { + const steamId = await fx.player(); + const { id } = await sanction(steamId, "warning"); + + await postgres.query( + `UPDATE player_sanctions + SET remove_sanction_date = now() + interval '1 day' + WHERE id = $1`, + [id], + ); + + const [row] = await postgres.query< + Array<{ remove_sanction_date: Date | null }> + >("SELECT remove_sanction_date FROM player_sanctions WHERE id = $1", [id]); + expect(row.remove_sanction_date).toBeNull(); + }); + + it("leaves the end date of every other type alone", async () => { + const steamId = await fx.player(); + const row = await sanction( + steamId, + "mute", + new Date(Date.now() + 86_400_000).toISOString(), + ); + + expect(row.remove_sanction_date).not.toBeNull(); + }); + + it("never hands a warning to the game server", async () => { + const warned = await fx.player(); + const muted = await fx.player(); + await sanction(warned, "warning"); + await sanction(muted, "mute"); + + expect(await service().getActiveServerSanctions("server-1")).toEqual([ + { steam_id: muted, is_banned: false, is_muted: true, is_gagged: false }, + ]); + }); + + it("removes one warning by id and leaves the rest of the record", async () => { + const steamId = await fx.player(); + const first = await sanction(steamId, "warning"); + const second = await sanction(steamId, "warning"); + + const result = await service().unsanctionServerPlayer({ + steamId, + type: "warning", + sanctionId: first.id, + }); + + expect(result.id).toBe(first.id); + const active = await postgres.query>( + `SELECT id FROM player_sanctions + WHERE player_steam_id = $1::bigint + AND deleted_at IS NULL`, + [steamId], + ); + expect(active.map(({ id }) => id)).toEqual([second.id]); + }); + + it("will not remove somebody else's sanction by id", async () => { + const steamId = await fx.player(); + const other = await fx.player(); + const theirs = await sanction(other, "warning"); + + const result = await service().unsanctionServerPlayer({ + steamId, + type: "warning", + sanctionId: theirs.id, + }); + + expect(result.id).toBeNull(); + const [row] = await postgres.query>( + "SELECT deleted_at FROM player_sanctions WHERE id = $1", + [theirs.id], + ); + expect(row.deleted_at).toBeNull(); + }); +}); From 0ba2cd32fc4b58ccf7df7c9263552faf192a865a Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 15:46:50 -0400 Subject: [PATCH 4/5] feature: warning review fixes - removing a sanction by id fails with "sanction not found" when no row matched instead of reporting success - search sanction count lists the enforced types rather than naming `warning`, so indexing survives a Hasura that has not reloaded the enum yet - SanctionType and the enforced-type list live in sanction-types.ts; importing SanctionsService from type-sense would have closed a cycle through RconService --- src/sanctions/sanction-types.ts | 11 +++++++++++ src/sanctions/sanctions.controller.ts | 3 ++- src/sanctions/sanctions.service.spec.ts | 17 +++++++++++++++++ src/sanctions/sanctions.service.ts | 18 +++++++----------- src/type-sense/type-sense.service.ts | 3 ++- test/sanctions-warning.spec.ts | 13 +++++++------ 6 files changed, 46 insertions(+), 19 deletions(-) create mode 100644 src/sanctions/sanction-types.ts diff --git a/src/sanctions/sanction-types.ts b/src/sanctions/sanction-types.ts new file mode 100644 index 00000000..072dc9ca --- /dev/null +++ b/src/sanctions/sanction-types.ts @@ -0,0 +1,11 @@ +export type SanctionType = "ban" | "mute" | "gag" | "silence" | "warning"; + +// Also the only types the public record counts: a warning is private to the +// player and staff. Lives outside SanctionsService so the search index can read +// it without importing rcon, which already imports the search service. +export const SERVER_ENFORCED_SANCTION_TYPES: SanctionType[] = [ + "ban", + "mute", + "gag", + "silence", +]; diff --git a/src/sanctions/sanctions.controller.ts b/src/sanctions/sanctions.controller.ts index dbff66eb..5ef2fc5c 100644 --- a/src/sanctions/sanctions.controller.ts +++ b/src/sanctions/sanctions.controller.ts @@ -2,7 +2,8 @@ import { Controller, Get, Param } from "@nestjs/common"; import { HasuraAction } from "src/hasura/hasura.controller"; import { User } from "src/auth/types/User"; import { isRoleAbove } from "src/utilities/isRoleAbove"; -import { SanctionsService, SanctionType } from "./sanctions.service"; +import { SanctionsService } from "./sanctions.service"; +import { SanctionType } from "./sanction-types"; @Controller("sanctions") export class SanctionsController { diff --git a/src/sanctions/sanctions.service.spec.ts b/src/sanctions/sanctions.service.spec.ts index 3533d5c7..8d66953a 100644 --- a/src/sanctions/sanctions.service.spec.ts +++ b/src/sanctions/sanctions.service.spec.ts @@ -167,6 +167,21 @@ describe("SanctionsService", () => { }); }); + it("says so when the named row is not there to remove", async () => { + postgres.query.mockResolvedValueOnce([]); + + await expect( + service.unsanctionServerPlayer({ + serverId: "server-1", + steamId, + type: "ban", + sanctionId: "sanction-7", + }), + ).rejects.toThrow("sanction not found"); + + expect(rconService.connect).not.toHaveBeenCalled(); + }); + it("refuses to clear warnings by type alone", async () => { await expect( service.unsanctionServerPlayer({ steamId, type: "warning" }), @@ -176,6 +191,8 @@ describe("SanctionsService", () => { }); it("never syncs the server when a warning is removed", async () => { + postgres.query.mockResolvedValueOnce([{ id: "sanction-7" }]); + await service.unsanctionServerPlayer({ serverId: "server-1", steamId, diff --git a/src/sanctions/sanctions.service.ts b/src/sanctions/sanctions.service.ts index c6049b42..22bfe0f9 100644 --- a/src/sanctions/sanctions.service.ts +++ b/src/sanctions/sanctions.service.ts @@ -3,8 +3,7 @@ import { HasuraService } from "src/hasura/hasura.service"; import { PostgresService } from "src/postgres/postgres.service"; import { RconService } from "src/rcon/rcon.service"; import { DedicatedServersService } from "src/dedicated-servers/dedicated-servers.service"; - -export type SanctionType = "ban" | "mute" | "gag" | "silence" | "warning"; +import { SanctionType, SERVER_ENFORCED_SANCTION_TYPES } from "./sanction-types"; @Injectable() export class SanctionsService { @@ -24,13 +23,6 @@ export class SanctionsService { "warning", ]; - public static readonly SERVER_ENFORCED_TYPES: SanctionType[] = [ - "ban", - "mute", - "gag", - "silence", - ]; - public async getActiveServerSanctions(serverId: string): Promise< Array<{ steam_id: string; @@ -47,7 +39,7 @@ export class SanctionsService { WHERE deleted_at IS NULL AND type = ANY($1::text[]) AND (remove_sanction_date IS NULL OR remove_sanction_date > now())`, - [SanctionsService.SERVER_ENFORCED_TYPES], + [SERVER_ENFORCED_SANCTION_TYPES], ); const byPlayer: Record< @@ -181,6 +173,10 @@ export class SanctionsService { [sanctionId, steamId, type], ); removedId = removed.at(0)?.id ?? null; + + if (!removedId) { + throw Error("sanction not found"); + } } else { await this.postgres.query( `UPDATE public.player_sanctions @@ -195,7 +191,7 @@ export class SanctionsService { let enforced = false; let message = type === "warning" ? "warning removed" : "sanction removed"; - if (serverId && SanctionsService.SERVER_ENFORCED_TYPES.includes(type)) { + if (serverId && SERVER_ENFORCED_SANCTION_TYPES.includes(type)) { const result = await this.syncServer(serverId, null); enforced = result.enforced; message = result.message; diff --git a/src/type-sense/type-sense.service.ts b/src/type-sense/type-sense.service.ts index 02544c4b..1f1a4c68 100644 --- a/src/type-sense/type-sense.service.ts +++ b/src/type-sense/type-sense.service.ts @@ -12,6 +12,7 @@ import { import { InjectQueue } from "@nestjs/bullmq"; import { Queue } from "bullmq"; import { PostgresService } from "../postgres/postgres.service"; +import { SERVER_ENFORCED_SANCTION_TYPES } from "../sanctions/sanction-types"; import { RefreshAllPlayersJob } from "./jobs/RefreshAllPlayers"; // One publicly visible lineup, as the global search bar needs it. Only ever @@ -532,7 +533,7 @@ export class TypeSenseService { __args: { where: { type: { - _neq: "warning", + _in: SERVER_ENFORCED_SANCTION_TYPES, }, }, }, diff --git a/test/sanctions-warning.spec.ts b/test/sanctions-warning.spec.ts index c6d6f964..518a164d 100644 --- a/test/sanctions-warning.spec.ts +++ b/test/sanctions-warning.spec.ts @@ -180,13 +180,14 @@ describe("warning sanctions (SQL-driven)", () => { const other = await fx.player(); const theirs = await sanction(other, "warning"); - const result = await service().unsanctionServerPlayer({ - steamId, - type: "warning", - sanctionId: theirs.id, - }); + await expect( + service().unsanctionServerPlayer({ + steamId, + type: "warning", + sanctionId: theirs.id, + }), + ).rejects.toThrow("sanction not found"); - expect(result.id).toBeNull(); const [row] = await postgres.query>( "SELECT deleted_at FROM player_sanctions WHERE id = $1", [theirs.id], From 55f826dcc3998693066bd9076960ae860f1e7529 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 20:21:34 -0400 Subject: [PATCH 5/5] test: prove warnings stay out of the public search sanction count --- src/type-sense/type-sense.sanctions.spec.ts | 67 +++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/type-sense/type-sense.sanctions.spec.ts diff --git a/src/type-sense/type-sense.sanctions.spec.ts b/src/type-sense/type-sense.sanctions.spec.ts new file mode 100644 index 00000000..1a008add --- /dev/null +++ b/src/type-sense/type-sense.sanctions.spec.ts @@ -0,0 +1,67 @@ +import { TypeSenseService } from "./type-sense.service"; + +describe("TypeSenseService player sanctions count", () => { + let hasura: { query: jest.Mock }; + let upsert: jest.Mock; + let service: TypeSenseService; + + beforeEach(() => { + hasura = { query: jest.fn() }; + upsert = jest.fn().mockResolvedValue({}); + + service = new TypeSenseService( + { log: jest.fn(), warn: jest.fn(), error: jest.fn() } as any, + { get: jest.fn() } as any, + hasura as any, + { sendServerMatchId: jest.fn() } as any, + { add: jest.fn() } as any, + { add: jest.fn() } as any, + { query: jest.fn() } as any, + ); + + (service as any).client = { + collections: jest.fn(() => ({ + documents: jest.fn(() => ({ upsert })), + })), + }; + + hasura.query + .mockResolvedValueOnce({ + players_by_pk: { + elo: {}, + name: "Player", + role: "user", + country: null, + avatar_url: null, + custom_avatar_url: null, + roster_image_url: null, + profile_url: null, + is_banned: false, + is_gagged: false, + is_muted: false, + teams: [], + last_sign_in_at: null, + wins: 0, + losses: 0, + total_matches: 0, + stats: { kills: 0, deaths: 0 }, + sanctions_aggregate: { aggregate: { count: 2 } }, + }, + }) + .mockResolvedValueOnce({ match_lineup_players: [] }); + }); + + it("counts only the enforced sanction types, never warnings", async () => { + await service.updatePlayer("76561198000000000"); + + const { sanctions_aggregate } = hasura.query.mock.calls[0][0].players_by_pk; + + expect(sanctions_aggregate.__args?.where).toEqual({ + type: { _in: ["ban", "mute", "gag", "silence"] }, + }); + expect(JSON.stringify(sanctions_aggregate)).not.toContain("warning"); + expect(upsert.mock.calls[0][0]).toEqual( + expect.objectContaining({ sanctions: 2 }), + ); + }); +});