From 892309b48a25a29f9dcb350d64104d005c331386 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 15:43:38 -0400 Subject: [PATCH 1/4] =?UTF-8?q?feature:=20chat=20moderation=20=E2=80=94=20?= =?UTF-8?q?gag=20blocks=20website=20chat,=20moderators=20delete=20messages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A gag (or silence) now also refuses website sends in every group room with chat:error { code: "gagged" }; DMs and lines relayed from the game are left alone. Moderators and up can delete any message in a group room they can access via lobby:delete; the message is audited to chat_message_deletions before it is removed from redis, the room gets lobby:::deleted, and its unread bell rows / pending pushes are retracted. --- hasura/enums/sanction-types.sql | 2 +- .../tables/public_chat_message_deletions.yaml | 27 + .../databases/default/tables/tables.yaml | 1 + .../down.sql | 1 + .../up.sql | 20 + src/chat/chat.gateway.spec.ts | 158 ++++++ src/chat/chat.gateway.ts | 43 ++ src/chat/chat.service.spec.ts | 425 ++++++++++++++- src/chat/chat.service.ts | 142 ++++- src/chat/enums/ChatErrorCode.ts | 2 + src/chat/types/ChatDeleteResult.ts | 5 + src/notifications/notifications.service.ts | 19 + .../push/push-notifications.service.ts | 1 + test/chat-moderation.spec.ts | 500 ++++++++++++++++++ 14 files changed, 1343 insertions(+), 3 deletions(-) create mode 100644 hasura/metadata/databases/default/tables/public_chat_message_deletions.yaml create mode 100644 hasura/migrations/default/1888000000100_chat_message_deletions/down.sql create mode 100644 hasura/migrations/default/1888000000100_chat_message_deletions/up.sql create mode 100644 src/chat/types/ChatDeleteResult.ts create mode 100644 test/chat-moderation.spec.ts diff --git a/hasura/enums/sanction-types.sql b/hasura/enums/sanction-types.sql index bf6877ce..217f064e 100644 --- a/hasura/enums/sanction-types.sql +++ b/hasura/enums/sanction-types.sql @@ -1,7 +1,7 @@ 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'), + ('gag', 'Player cannot use text chat in game or in website lobby chat'), ('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/databases/default/tables/public_chat_message_deletions.yaml b/hasura/metadata/databases/default/tables/public_chat_message_deletions.yaml new file mode 100644 index 00000000..4d5ebdf4 --- /dev/null +++ b/hasura/metadata/databases/default/tables/public_chat_message_deletions.yaml @@ -0,0 +1,27 @@ +table: + name: chat_message_deletions + schema: public +object_relationships: + - name: author + using: + foreign_key_constraint_on: author_steam_id + - name: deleted_by + using: + foreign_key_constraint_on: deleted_by_steam_id +select_permissions: + - role: moderator + permission: + columns: + - id + - message_id + - room_type + - room_id + - author_steam_id + - message + - message_created_at + - source + - deleted_by_steam_id + - deleted_at + filter: {} + allow_aggregations: true + comment: Evidence of moderated website chat. Written only by the API. diff --git a/hasura/metadata/databases/default/tables/tables.yaml b/hasura/metadata/databases/default/tables/tables.yaml index 2ae6e1fc..32afb034 100644 --- a/hasura/metadata/databases/default/tables/tables.yaml +++ b/hasura/metadata/databases/default/tables/tables.yaml @@ -5,6 +5,7 @@ - "!include public_award_recipients.yaml" - "!include public_awards.yaml" - "!include public_broadcast_huds.yaml" +- "!include public_chat_message_deletions.yaml" - "!include public_chat_read_state.yaml" - "!include public_clip_render_jobs.yaml" - "!include public_custom_pages.yaml" diff --git a/hasura/migrations/default/1888000000100_chat_message_deletions/down.sql b/hasura/migrations/default/1888000000100_chat_message_deletions/down.sql new file mode 100644 index 00000000..d66a5058 --- /dev/null +++ b/hasura/migrations/default/1888000000100_chat_message_deletions/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS public.chat_message_deletions; diff --git a/hasura/migrations/default/1888000000100_chat_message_deletions/up.sql b/hasura/migrations/default/1888000000100_chat_message_deletions/up.sql new file mode 100644 index 00000000..57ff9fc4 --- /dev/null +++ b/hasura/migrations/default/1888000000100_chat_message_deletions/up.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS public.chat_message_deletions ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + message_id uuid NOT NULL, + room_type text NOT NULL, + room_id text NOT NULL, + author_steam_id bigint REFERENCES public.players (steam_id) + ON UPDATE CASCADE ON DELETE SET NULL, + message text NOT NULL, + message_created_at timestamptz, + source text, + deleted_by_steam_id bigint REFERENCES public.players (steam_id) + ON UPDATE CASCADE ON DELETE SET NULL, + deleted_at timestamptz NOT NULL DEFAULT now(), + + PRIMARY KEY (id), + UNIQUE (room_type, room_id, message_id) +); + +CREATE INDEX IF NOT EXISTS chat_message_deletions_author_idx + ON public.chat_message_deletions (author_steam_id, deleted_at DESC); diff --git a/src/chat/chat.gateway.spec.ts b/src/chat/chat.gateway.spec.ts index f7e173aa..b56de6f7 100644 --- a/src/chat/chat.gateway.spec.ts +++ b/src/chat/chat.gateway.spec.ts @@ -185,6 +185,29 @@ describe("ChatGateway lobby:chat", () => { ]); }); + it("tells a gagged sender why, and never relays them", async () => { + chat.sendMessageToChat.mockResolvedValue({ + accepted: false, + code: ChatErrorCode.Gagged, + }); + const socket = client(); + + await gateway.lobby( + { + id: "m-1", + type: ChatLobbyType.Match, + message: "gg", + requestId: "r-4", + }, + socket, + ); + + expect(chat.sendChatToServer).not.toHaveBeenCalled(); + expect(sent(socket)).toEqual([ + { event: "chat:error", data: { code: "gagged", requestId: "r-4" } }, + ]); + }); + it("says nothing about a refusal that carries no code", async () => { chat.sendMessageToChat.mockResolvedValue({ accepted: false }); const socket = client(); @@ -276,3 +299,138 @@ describe("ChatGateway lobby:chat", () => { }); }); }); + +describe("ChatGateway lobby:delete", () => { + const MESSAGE_ID = "3f0c1d2e-4b5a-4c6d-8e7f-9a0b1c2d3e4f"; + + let chat: { deleteMessage: jest.Mock }; + let gateway: ChatGateway; + + const client = ( + user: any = { steam_id: "1", name: "Mod", role: "moderator" }, + ) => ({ id: "client-1", user, send: jest.fn() }) as any; + + const sent = (socket: { send: jest.Mock }) => + socket.send.mock.calls.map(([raw]) => JSON.parse(raw)); + + beforeEach(() => { + chat = { deleteMessage: jest.fn().mockResolvedValue({ deleted: true }) }; + gateway = new ChatGateway(chat as any); + }); + + it("ignores a socket that has not signed in", async () => { + const socket = client(null); + + await gateway.deleteMessage( + { + id: "m-1", + type: ChatLobbyType.Match, + messageId: MESSAGE_ID, + requestId: "r-1", + }, + socket, + ); + + expect(chat.deleteMessage).not.toHaveBeenCalled(); + expect(socket.send).not.toHaveBeenCalled(); + }); + + it.each([ + ["an unknown lobby type", { type: "global", id: "m-1", messageId: "x" }], + [ + "a room id that is not a string", + { type: "match", id: 1, messageId: "x" }, + ], + ["a missing message id", { type: "match", id: "m-1" }], + ["a missing payload", undefined], + ])("ignores %s", async (_, data) => { + const socket = client(); + + await gateway.deleteMessage(data as any, socket); + + expect(chat.deleteMessage).not.toHaveBeenCalled(); + expect(socket.send).not.toHaveBeenCalled(); + }); + + it("asks the service to delete as the signed in player", async () => { + await gateway.deleteMessage( + { id: "m-1", type: ChatLobbyType.Match, messageId: MESSAGE_ID }, + client(), + ); + + expect(chat.deleteMessage).toHaveBeenCalledWith( + ChatLobbyType.Match, + "m-1", + MESSAGE_ID, + expect.objectContaining({ steam_id: "1" }), + ); + }); + + it("acks a deletion under the requestId it came with", async () => { + const socket = client(); + + await gateway.deleteMessage( + { + id: "m-1", + type: ChatLobbyType.Match, + messageId: MESSAGE_ID, + requestId: "r-2", + }, + socket, + ); + + expect(sent(socket)).toEqual([ + { event: "chat:ack", data: { requestId: "r-2", messageId: MESSAGE_ID } }, + ]); + }); + + it("stays quiet for a deletion without a requestId", async () => { + const socket = client(); + + await gateway.deleteMessage( + { id: "m-1", type: ChatLobbyType.Match, messageId: MESSAGE_ID }, + socket, + ); + + expect(socket.send).not.toHaveBeenCalled(); + }); + + it.each([ChatErrorCode.NotAllowed, ChatErrorCode.NotFound])( + "reports %s under the requestId it came with", + async (code) => { + chat.deleteMessage.mockResolvedValue({ deleted: false, code }); + const socket = client(); + + await gateway.deleteMessage( + { + id: "m-1", + type: ChatLobbyType.Match, + messageId: MESSAGE_ID, + requestId: "r-3", + }, + socket, + ); + + expect(sent(socket)).toEqual([ + { event: "chat:error", data: { code, requestId: "r-3" } }, + ]); + }, + ); + + it("still reports a refusal without a requestId", async () => { + chat.deleteMessage.mockResolvedValue({ + deleted: false, + code: ChatErrorCode.NotAllowed, + }); + const socket = client(); + + await gateway.deleteMessage( + { id: "x", type: ChatLobbyType.Direct, messageId: MESSAGE_ID }, + socket, + ); + + expect(sent(socket)).toEqual([ + { event: "chat:error", data: { code: "not_allowed" } }, + ]); + }); +}); diff --git a/src/chat/chat.gateway.ts b/src/chat/chat.gateway.ts index 52cc01ee..a51b2384 100644 --- a/src/chat/chat.gateway.ts +++ b/src/chat/chat.gateway.ts @@ -151,6 +151,49 @@ export class ChatGateway { ); } + @SubscribeMessage("lobby:delete") + async deleteMessage( + @MessageBody() + data: { + id: string; + type: ChatLobbyType; + messageId: string; + requestId?: string; + }, + @ConnectedSocket() client: FiveStackWebSocketClient, + ) { + if (!client.user) { + return; + } + + if ( + !ChatGateway.isLobbyType(data?.type) || + typeof data.id !== "string" || + typeof data.messageId !== "string" + ) { + return; + } + + const requestId = + typeof data.requestId === "string" ? data.requestId : undefined; + + const result = await this.chat.deleteMessage( + data.type, + data.id, + data.messageId, + client.user, + ); + + if (result.deleted === false) { + this.sendError(client, result.code, requestId); + return; + } + + if (requestId) { + this.sendAck(client, requestId, data.messageId); + } + } + private static isLobbyType(value: unknown): value is ChatLobbyType { return Object.values(ChatLobbyType).includes(value as ChatLobbyType); } diff --git a/src/chat/chat.service.spec.ts b/src/chat/chat.service.spec.ts index 82bf9cfa..badfe74b 100644 --- a/src/chat/chat.service.spec.ts +++ b/src/chat/chat.service.spec.ts @@ -36,13 +36,26 @@ describe("ChatService direct messages", () => { let acceptedFriendships: Array<[string, string]>; let role: string; let queries: Array<{ sql: string; bindings: any[] }>; + let gagged: boolean; const postgres = { query: jest.fn(async (sql: string, bindings: any[]): Promise => { queries.push({ sql, bindings }); + + if (sql.includes("public.is_gagged")) { + return [{ gagged }]; + } + return []; }), }; + const notifications = { + notifyPlayers: jest.fn(), + collapseOlderUnread: jest.fn(), + markConversationRead: jest.fn(), + retractChatMessage: jest.fn().mockResolvedValue(undefined), + }; + const client = (steamId: string) => ({ id: "client-1", @@ -235,7 +248,9 @@ describe("ChatService direct messages", () => { // clearAllMocks keeps implementations, so a test that seats someone in a // room would otherwise leave them seated for every test after it. redis.hget.mockResolvedValue(null); + redis.hgetall.mockResolvedValue({}); redis.get.mockResolvedValue(null); + notifications.retractChatMessage.mockResolvedValue(undefined); acceptedFriendships = [[ME, FRIEND]]; myMatches = ["m-1"]; otherMatches = ["mm-1"]; @@ -248,6 +263,7 @@ describe("ChatService direct messages", () => { staff = []; role = "user"; queries = []; + gagged = false; rcon.send.mockResolvedValue(undefined); rcon.connect.mockResolvedValue(rcon); @@ -257,7 +273,7 @@ describe("ChatService direct messages", () => { hasuraService as any, postgres as any, { getConnection: () => redis } as any, - { notifyPlayers: jest.fn(), markConversationRead: jest.fn() } as any, + notifications as any, ); }); @@ -839,6 +855,413 @@ describe("ChatService direct messages", () => { }); }); + describe("gag", () => { + const player = () => + ({ steam_id: ME, name: "Someone", role: "user" }) as any; + + const groupRooms = [ + ChatLobbyType.Match, + ChatLobbyType.MatchTeam, + ChatLobbyType.MatchMaking, + ChatLobbyType.Tournament, + ChatLobbyType.Draft, + ChatLobbyType.Organizer, + ChatLobbyType.Team, + ]; + + beforeEach(() => { + gagged = true; + }); + + it.each(groupRooms)( + "keeps a gagged player's website message out of a %s room", + async (type) => { + await expect( + service.sendMessageToChat(type, "x", player(), "hello", true), + ).resolves.toEqual({ accepted: false, code: ChatErrorCode.Gagged }); + + expect(redis.hset).not.toHaveBeenCalled(); + expect(redis.publish).not.toHaveBeenCalled(); + expect(notifications.notifyPlayers).not.toHaveBeenCalled(); + }, + ); + + it("refuses a gagged player in a room they belong to", async () => { + redis.hget.mockResolvedValue(JSON.stringify({ user: { steam_id: ME } })); + + await expect( + service.sendMessageToChat(ChatLobbyType.Match, "m-1", player(), "hi"), + ).resolves.toEqual({ accepted: false, code: ChatErrorCode.Gagged }); + + const [check] = queries.filter(({ sql }) => + sql.includes("public.is_gagged"), + ); + + expect(check.bindings).toEqual([ME]); + }); + + it("answers not_allowed rather than gagged for a room they are not in", async () => { + await expect( + service.sendMessageToChat(ChatLobbyType.Match, "m-2", player(), "hi"), + ).resolves.toEqual({ accepted: false, code: ChatErrorCode.NotAllowed }); + }); + + it("leaves a gagged player's direct messages alone", async () => { + redis.hget.mockResolvedValue(JSON.stringify({ user: { steam_id: ME } })); + + await expect( + service.sendMessageToChat( + ChatLobbyType.Direct, + directRoomId(ME, FRIEND), + player(), + "hi", + ), + ).resolves.toEqual({ accepted: true, messageId: expect.any(String) }); + + expect(queries.some(({ sql }) => sql.includes("public.is_gagged"))).toBe( + false, + ); + }); + + it("leaves a line relayed from the game to the game server's gag", async () => { + await expect( + service.sendMessageToChat( + ChatLobbyType.Match, + "m-1", + player(), + "from the server", + true, + "game", + ), + ).resolves.toEqual({ accepted: true, messageId: expect.any(String) }); + }); + + it("lets a player post once the gag is lifted", async () => { + gagged = false; + + await expect( + service.sendMessageToChat( + ChatLobbyType.Tournament, + "t-1", + player(), + "back", + true, + ), + ).resolves.toEqual({ accepted: true, messageId: expect.any(String) }); + }); + }); + + describe("deleting", () => { + const MESSAGE_ID = "3f0c1d2e-4b5a-4c6d-8e7f-9a0b1c2d3e4f"; + + const moderator = (overrides: Record = {}) => + ({ steam_id: ME, name: "Mod", role: "moderator", ...overrides }) as any; + + let stored: Record>; + + const store = ( + type: ChatLobbyType, + id: string, + message: Record = {}, + ) => { + stored[`chat_${type}_${id}`] = { + ...stored[`chat_${type}_${id}`], + [MESSAGE_ID]: JSON.stringify({ + id: MESSAGE_ID, + message: "something awful", + timestamp: "2025-01-01T00:00:00.000Z", + source: "web", + from: { role: "user", name: "Author", steam_id: FRIEND }, + ...message, + }), + }; + }; + + const audits = () => + queries.filter(({ sql }) => + sql.includes("INSERT INTO public.chat_message_deletions"), + ); + + const flush = () => new Promise((resolve) => setImmediate(resolve)); + + beforeEach(() => { + role = "moderator"; + stored = {}; + redis.hget.mockImplementation( + async (key: string, field: string) => stored[key]?.[field] ?? null, + ); + }); + + it("lets a moderator remove a message from a room they are in, however old", async () => { + store(ChatLobbyType.Match, "m-1"); + redis.hgetall.mockImplementation(async (key: string) => + key === "chat:match:m-1" + ? { [FRIEND]: JSON.stringify({ user: { steam_id: FRIEND } }) } + : {}, + ); + + await expect( + service.deleteMessage( + ChatLobbyType.Match, + "m-1", + MESSAGE_ID, + moderator(), + ), + ).resolves.toEqual({ deleted: true }); + + expect(redis.hdel).toHaveBeenCalledWith("chat_match_m-1", MESSAGE_ID); + + await flush(); + + const broadcast = redis.publish.mock.calls + .map(([, payload]) => JSON.parse(payload)) + .find(({ event }) => event === "lobby:match:m-1:deleted"); + + expect(broadcast).toEqual({ + steamId: FRIEND, + event: "lobby:match:m-1:deleted", + data: { id: MESSAGE_ID }, + }); + }); + + it("keeps the evidence of what was removed", async () => { + store(ChatLobbyType.Match, "m-1"); + + await service.deleteMessage( + ChatLobbyType.Match, + "m-1", + MESSAGE_ID, + moderator(), + ); + + expect(audits().at(0)?.bindings).toEqual([ + MESSAGE_ID, + "match", + "m-1", + FRIEND, + "something awful", + "2025-01-01T00:00:00.000Z", + "web", + ME, + ]); + }); + + it("writes the audit row before the message is removed", async () => { + store(ChatLobbyType.Match, "m-1"); + + await service.deleteMessage( + ChatLobbyType.Match, + "m-1", + MESSAGE_ID, + moderator(), + ); + + const auditCall = postgres.query.mock.calls.findIndex(([sql]) => + sql.includes("INSERT INTO public.chat_message_deletions"), + ); + + expect(postgres.query.mock.invocationCallOrder[auditCall]).toBeLessThan( + redis.hdel.mock.invocationCallOrder[0], + ); + }); + + it("leaves the message in place when the audit row cannot be written", async () => { + store(ChatLobbyType.Match, "m-1"); + postgres.query.mockRejectedValueOnce(new Error("database down")); + + await expect( + service.deleteMessage( + ChatLobbyType.Match, + "m-1", + MESSAGE_ID, + moderator(), + ), + ).rejects.toThrow("database down"); + + expect(redis.hdel).not.toHaveBeenCalled(); + expect(notifications.retractChatMessage).not.toHaveBeenCalled(); + }); + + it("records no author for a steam id stored as a number", async () => { + store(ChatLobbyType.Match, "m-1", { + from: { role: "user", name: "Author", steam_id: 76561198000000002 }, + }); + + await service.deleteMessage( + ChatLobbyType.Match, + "m-1", + MESSAGE_ID, + moderator(), + ); + + expect(audits().at(0)?.bindings[3]).toBeNull(); + }); + + it("retracts the message's notifications", async () => { + store(ChatLobbyType.Match, "m-1"); + + await service.deleteMessage( + ChatLobbyType.Match, + "m-1", + MESSAGE_ID, + moderator(), + ); + + expect(notifications.retractChatMessage).toHaveBeenCalledWith( + "MatchChatMessage", + "match:m-1", + MESSAGE_ID, + ); + }); + + it("retracts under the room's own notification type", async () => { + store(ChatLobbyType.Tournament, "t-1"); + tournament.roster = [ME]; + + await service.deleteMessage( + ChatLobbyType.Tournament, + "t-1", + MESSAGE_ID, + moderator(), + ); + + expect(notifications.retractChatMessage).toHaveBeenCalledWith( + "ChatMessage", + "tournament:t-1", + MESSAGE_ID, + ); + }); + + it("still deletes when the retraction fails", async () => { + store(ChatLobbyType.Match, "m-1"); + notifications.retractChatMessage.mockRejectedValue(new Error("nope")); + + await expect( + service.deleteMessage( + ChatLobbyType.Match, + "m-1", + MESSAGE_ID, + moderator(), + ), + ).resolves.toEqual({ deleted: true }); + }); + + it("refuses a streamer", async () => { + role = "streamer"; + store(ChatLobbyType.Match, "m-1"); + + await expect( + service.deleteMessage( + ChatLobbyType.Match, + "m-1", + MESSAGE_ID, + moderator({ role: "streamer" }), + ), + ).resolves.toEqual({ deleted: false, code: ChatErrorCode.NotAllowed }); + + expect(audits()).toHaveLength(0); + expect(redis.hdel).not.toHaveBeenCalled(); + }); + + it("goes by the role on record, not the one the socket signed in with", async () => { + role = "user"; + store(ChatLobbyType.Match, "m-1"); + + await expect( + service.deleteMessage( + ChatLobbyType.Match, + "m-1", + MESSAGE_ID, + moderator({ role: "administrator" }), + ), + ).resolves.toEqual({ deleted: false, code: ChatErrorCode.NotAllowed }); + + expect(redis.hdel).not.toHaveBeenCalled(); + }); + + it("refuses a moderator in a room they cannot get into", async () => { + store(ChatLobbyType.Match, "m-2"); + + await expect( + service.deleteMessage( + ChatLobbyType.Match, + "m-2", + MESSAGE_ID, + moderator(), + ), + ).resolves.toEqual({ deleted: false, code: ChatErrorCode.NotAllowed }); + + expect(redis.hdel).not.toHaveBeenCalled(); + }); + + it("refuses anyone in a direct conversation", async () => { + role = "administrator"; + + await expect( + service.deleteMessage( + ChatLobbyType.Direct, + directRoomId(ME, FRIEND), + MESSAGE_ID, + moderator({ role: "administrator" }), + ), + ).resolves.toEqual({ deleted: false, code: ChatErrorCode.NotAllowed }); + + expect(redis.hget).not.toHaveBeenCalled(); + expect(queries).toHaveLength(0); + }); + + it("answers not_found for a message that is not there", async () => { + await expect( + service.deleteMessage( + ChatLobbyType.Match, + "m-1", + MESSAGE_ID, + moderator(), + ), + ).resolves.toEqual({ deleted: false, code: ChatErrorCode.NotFound }); + + expect(audits()).toHaveLength(0); + expect(redis.hdel).not.toHaveBeenCalled(); + }); + + it("answers not_found for an id that could never be a message", async () => { + await expect( + service.deleteMessage( + ChatLobbyType.Match, + "m-1", + "not-a-uuid", + moderator(), + ), + ).resolves.toEqual({ deleted: false, code: ChatErrorCode.NotFound }); + + expect(redis.hget).not.toHaveBeenCalled(); + }); + + it("stamps each chat notification with the message it announces", async () => { + redis.hget.mockResolvedValue(JSON.stringify({ user: { steam_id: ME } })); + role = "user"; + + const result = await service.sendMessageToChat( + ChatLobbyType.Direct, + directRoomId(ME, FRIEND), + { steam_id: ME, name: "Someone", role: "user" } as any, + "hi", + ); + + await flush(); + + expect(result.accepted).toBe(true); + expect(notifications.notifyPlayers).toHaveBeenCalledWith( + "ChatMessage", + expect.objectContaining({ + data: expect.objectContaining({ + messageId: result.accepted ? result.messageId : undefined, + }), + }), + ); + }); + }); + describe("rosters", () => { it("resolves both parties of a conversation", async () => { expect( diff --git a/src/chat/chat.service.ts b/src/chat/chat.service.ts index 00ad315e..fcda19c9 100644 --- a/src/chat/chat.service.ts +++ b/src/chat/chat.service.ts @@ -21,6 +21,7 @@ import { parseDirectRoomId } from "./utilities/directRoomId"; import { ChatErrorCode } from "./enums/ChatErrorCode"; import { ChatMessage, ChatMessageSource } from "./types/ChatMessage"; import { ChatSendResult } from "./types/ChatSendResult"; +import { ChatDeleteResult } from "./types/ChatDeleteResult"; @Injectable() export class ChatService { @@ -574,6 +575,17 @@ export class ChatService { return { accepted: false, code: ChatErrorCode.NotAllowed }; } + // The game server already enforces a gag on what is typed in game, and a + // gag is a sanction on group chat -- a conversation between friends is left + // alone. + if ( + source === "web" && + type !== ChatLobbyType.Direct && + (await this.isGagged(player.steam_id)) + ) { + return { accepted: false, code: ChatErrorCode.Gagged }; + } + const name = await this.redis.get( HasuraService.PLAYER_NAME_CACHE_KEY(player.steam_id), ); @@ -632,6 +644,7 @@ export class ChatService { player, message.from.name, text, + message.id, ).catch((error) => { this.logger.warn(`unable to notify ${type}:${id} of a message`, error); }); @@ -661,6 +674,131 @@ export class ChatService { return await this.canAccessLobby(type, id, user); } + private async isGagged(steamId: string): Promise { + const [row] = await this.postgres.query>( + `SELECT public.is_gagged(p) AS gagged + FROM public.players p + WHERE p.steam_id = $1::bigint`, + [String(steamId)], + ); + + return row?.gagged === true; + } + + private static readonly UUID = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + + public async deleteMessage( + type: ChatLobbyType, + id: string, + messageId: string, + user: User, + ): Promise { + if (type === ChatLobbyType.Direct) { + return { deleted: false, code: ChatErrorCode.NotAllowed }; + } + + const current = await this.getCurrentUser(user.steam_id); + + if (!current) { + return { deleted: false, code: ChatErrorCode.NotAllowed }; + } + + const messageKey = `chat_${type}_${id}`; + const raw = ChatService.UUID.test(messageId) + ? await this.redis.hget(messageKey, messageId) + : null; + + if (!raw) { + return { deleted: false, code: ChatErrorCode.NotFound }; + } + + const message = JSON.parse(raw) as ChatMessage; + + if (!(await this.canDelete(message, type, id, current))) { + return { deleted: false, code: ChatErrorCode.NotAllowed }; + } + + // Audited before it is removed, so no failure part way through can take a + // message down without its evidence. A retry finds the row and carries on. + await this.recordDeletion(type, id, messageId, message, current); + + await this.redis.hdel(messageKey, messageId); + + void this.to(type, id, "deleted", { id: messageId }); + + await this.notifications + .retractChatMessage( + ChatService.notificationTypeFor(type), + `${type}:${id}`, + messageId, + ) + .catch((error) => { + this.logger.warn( + `unable to retract notifications for ${type}:${id} message ${messageId}`, + error, + ); + }); + + return { deleted: true }; + } + + private async canDelete( + message: ChatMessage, + type: ChatLobbyType, + id: string, + user: User, + ): Promise { + if (!isRoleAbove(user.role, "moderator")) { + return false; + } + + return await this.canAccessLobby(type, id, user); + } + + private async recordDeletion( + type: ChatLobbyType, + id: string, + messageId: string, + message: ChatMessage, + deletedBy: User, + ) { + const createdAt = new Date(message.timestamp); + + await this.postgres.query( + `INSERT INTO public.chat_message_deletions + (message_id, room_type, room_id, author_steam_id, message, + message_created_at, source, deleted_by_steam_id) + SELECT $1::uuid, $2, $3, + (SELECT steam_id FROM public.players + WHERE steam_id = $4::bigint), + $5, $6::timestamptz, $7, $8::bigint + ON CONFLICT (room_type, room_id, message_id) DO NOTHING`, + [ + messageId, + type, + id, + ChatService.authorSteamId(message), + String(message.message ?? ""), + Number.isNaN(createdAt.getTime()) ? null : createdAt.toISOString(), + message.source ?? null, + deletedBy.steam_id, + ], + ); + } + + // A steam id stored as a JSON number has already been rounded by JSON.parse + // onto some other account, and would pin the message on the wrong player. + private static authorSteamId(message: ChatMessage): string | null { + const steamId: unknown = message.from?.steam_id; + + if (typeof steamId !== "string" || !/^\d{1,20}$/.test(steamId)) { + return null; + } + + return steamId; + } + // The name and role caches are separate keys with separate lifetimes, so // either can be missing while the other is not. private static cachedOr(cached: string | null, fallback: T): T { @@ -691,6 +829,7 @@ export class ChatService { sender: User, senderName: string, message: string, + messageId: string, ) { const members = await this.getLobbyMemberSteamIds(type, id); const senderSteamId = String(sender.steam_id); @@ -717,6 +856,7 @@ export class ChatService { threadLabel: await this.threadLabel(type, id, sender), icon: sender.avatar_url, senderSteamId, + messageId, }, }); @@ -1427,7 +1567,7 @@ export class ChatService { public async to( type: ChatLobbyType, id: string, - event: "chat" | "list" | "messages" | "joined" | "left", + event: "chat" | "deleted" | "list" | "messages" | "joined" | "left", data: Record, ) { const users = await this.getAllUsersInLobby(type, id); diff --git a/src/chat/enums/ChatErrorCode.ts b/src/chat/enums/ChatErrorCode.ts index 8f3e3b1c..35624e17 100644 --- a/src/chat/enums/ChatErrorCode.ts +++ b/src/chat/enums/ChatErrorCode.ts @@ -4,4 +4,6 @@ export enum ChatErrorCode { TooLong = "too_long", NotAllowed = "not_allowed", Invalid = "invalid", + Gagged = "gagged", + NotFound = "not_found", } diff --git a/src/chat/types/ChatDeleteResult.ts b/src/chat/types/ChatDeleteResult.ts new file mode 100644 index 00000000..4484c771 --- /dev/null +++ b/src/chat/types/ChatDeleteResult.ts @@ -0,0 +1,5 @@ +import { ChatErrorCode } from "../enums/ChatErrorCode"; + +export type ChatDeleteResult = + | { deleted: true } + | { deleted: false; code: ChatErrorCode }; diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts index f42a03c4..de00b471 100644 --- a/src/notifications/notifications.service.ts +++ b/src/notifications/notifications.service.ts @@ -769,6 +769,25 @@ export class NotificationsService { ); } + // Soft-deleting is also what stops a push still waiting in a bundling window, + // since chat delivery skips deleted rows. Rows collapseOlderUnread already + // retired stay retired, even when the one that superseded them goes. + async retractChatMessage( + type: e_notification_types_enum, + entityId: string, + messageId: string, + ) { + await this.postgres.query( + `UPDATE public.notifications + SET deleted_at = now() + WHERE type = $1 + AND entity_id = $2 + AND data->>'messageId' = $3 + AND deleted_at IS NULL`, + [type, entityId, messageId], + ); + } + // Opening a conversation should clear its badge everywhere, not just in the // tab that was open. async markConversationRead( diff --git a/src/notifications/push/push-notifications.service.ts b/src/notifications/push/push-notifications.service.ts index f809f706..3be5e9bc 100644 --- a/src/notifications/push/push-notifications.service.ts +++ b/src/notifications/push/push-notifications.service.ts @@ -41,6 +41,7 @@ export type NotificationData = { icon?: string | null; image?: string | null; senderSteamId?: string; + messageId?: string; }; // The bell's buttons, as written by NotificationsService.send / notifyPlayers. diff --git a/test/chat-moderation.spec.ts b/test/chat-moderation.spec.ts new file mode 100644 index 00000000..9f8d970c --- /dev/null +++ b/test/chat-moderation.spec.ts @@ -0,0 +1,500 @@ +import { randomUUID } from "crypto"; +import { readFileSync } from "fs"; +import { join } from "path"; +import * as webPush from "web-push"; +import IORedis, { Redis } from "ioredis"; +import { GenericContainer, StartedTestContainer } from "testcontainers"; +import { PostgresService } from "./../src/postgres/postgres.service"; +import { Fixtures } from "./utils/fixtures"; +import { bootMigratedDb, SqlTestDb } from "./utils/sql-test-db"; +import { ChatService } from "./../src/chat/chat.service"; +import { ChatErrorCode } from "./../src/chat/enums/ChatErrorCode"; +import { ChatLobbyType } from "./../src/chat/enums/ChatLobbyTypes"; +import { NotificationsService } from "./../src/notifications/notifications.service"; +import { NotificationPreferencesService } from "./../src/notifications/preferences/notification-preferences.service"; +import { PushNotificationsService } from "./../src/notifications/push/push-notifications.service"; + +jest.mock("web-push", () => ({ + setVapidDetails: jest.fn(), + sendNotification: jest.fn().mockResolvedValue({}), + generateVAPIDKeys: jest.fn(), +})); + +// The audit row, the redis removal and the bell retraction each live in a +// different store, and the unit specs stub all three. +describe("chat moderation (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let fx: Fixtures; + let container: StartedTestContainer; + let redis: Redis; + let chat: ChatService; + let notifications: NotificationsService; + + const logger = { log: jest.fn(), warn: jest.fn(), error: jest.fn() }; + + const up = readFileSync( + join( + __dirname, + "../hasura/migrations/default/1888000000100_chat_message_deletions/up.sql", + ), + "utf8", + ); + + const hasura = () => ({ + query: jest.fn(async (query: any) => { + if (query.players_by_pk) { + const [player] = await postgres.query< + Array<{ steam_id: string; name: string; role: string }> + >( + `SELECT steam_id::text AS steam_id, name, role::text AS role + FROM players WHERE steam_id = $1::bigint`, + [query.players_by_pk.__args.steam_id], + ); + return { players_by_pk: player ?? null }; + } + + // Who gets into which room is chat.service.spec's subject. + if (query.matches_by_pk) { + return { + matches_by_pk: { + is_coach: false, + is_organizer: true, + is_in_lineup: false, + }, + }; + } + + return {}; + }), + }); + + const pushService = () => + new PushNotificationsService( + logger as any, + postgres, + { + get: (key: string) => + key === "app" + ? { webDomain: "https://example.com" } + : { + publicKey: "public-key", + privateKey: "private-key", + subject: "https://example.com", + }, + } as any, + { add: async () => ({}) } as any, + { + getConnection: () => ({ + exists: async () => 0, + set: async () => "OK", + get: async (): Promise => null, + ttl: async () => -2, + del: async () => 1, + rpush: async () => 1, + expire: async () => 1, + multi: () => ({ + lrange() { + return this; + }, + del() { + return this; + }, + rpush() { + return this; + }, + expire() { + return this; + }, + exec: async (): Promise> => [[null, []]], + }), + pipeline: () => { + const queued: string[] = []; + return { + set: () => {}, + hvals: (key: string) => queued.push(key), + exec: async (): Promise> => + queued.map(() => [null, []] as [unknown, Array]), + }; + }, + subscribe: async () => 1, + publish: async () => 1, + on: () => {}, + }), + } as any, + ); + + beforeAll(async () => { + container = await new GenericContainer("redis:8.8-alpine") + .withExposedPorts(6379) + .start(); + redis = new IORedis({ + host: container.getHost(), + port: container.getMappedPort(6379), + }); + + db = await bootMigratedDb("ChatModerationTest"); + postgres = db.postgres; + fx = new Fixtures(postgres, 76561199600000000n); + + notifications = new NotificationsService( + hasura() as any, + postgres, + logger as any, + { get: () => ({ webDomain: "https://example.com" }) } as any, + new NotificationPreferencesService(postgres), + { add: jest.fn() } as any, + { add: jest.fn() } as any, + { add: jest.fn() } as any, + ); + + chat = new ChatService( + logger as any, + {} as any, + hasura() as any, + postgres, + { getConnection: () => redis } as any, + notifications, + ); + }, 600_000); + + afterAll(async () => { + redis?.disconnect(); + await container?.stop(); + await db?.stop(); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await redis.flushall(); + await postgres.query("DELETE FROM chat_message_deletions"); + await postgres.query("DELETE FROM player_sanctions"); + await postgres.query("DELETE FROM push_subscriptions"); + await postgres.query("DELETE FROM notifications"); + await postgres.query("DELETE FROM players"); + }); + + const moderator = async () => { + const steamId = await fx.player("Mod"); + await postgres.query( + `UPDATE players SET role = 'moderator' WHERE steam_id = $1::bigint`, + [steamId], + ); + return { steam_id: steamId, name: "Mod", role: "moderator" } as any; + }; + + const post = async ( + matchId: string, + authorSteamId: string, + message = "something awful", + ) => { + const id = randomUUID(); + await redis.hset( + `chat_match_${matchId}`, + id, + JSON.stringify({ + id, + message, + timestamp: "2025-01-01T00:00:00.000Z", + source: "web", + from: { role: "user", name: "Author", steam_id: authorSteamId }, + }), + ); + return id; + }; + + const audits = () => + postgres.query< + Array<{ + message_id: string; + room_type: string; + room_id: string; + author_steam_id: string | null; + message: string; + message_created_at: Date | null; + source: string | null; + deleted_by_steam_id: string | null; + }> + >( + `SELECT message_id::text AS message_id, room_type, room_id, + author_steam_id::text AS author_steam_id, message, + message_created_at, source, + deleted_by_steam_id::text AS deleted_by_steam_id + FROM chat_message_deletions`, + ); + + describe("deleting", () => { + it("keeps the evidence and removes only that message", async () => { + const mod = await moderator(); + const author = await fx.player("Author"); + const matchId = randomUUID(); + const target = await post(matchId, author); + const other = await post(matchId, author, "fine"); + + await expect( + chat.deleteMessage(ChatLobbyType.Match, matchId, target, mod), + ).resolves.toEqual({ deleted: true }); + + expect(await redis.hexists(`chat_match_${matchId}`, target)).toBe(0); + expect(await redis.hexists(`chat_match_${matchId}`, other)).toBe(1); + + expect(await audits()).toEqual([ + { + message_id: target, + room_type: "match", + room_id: matchId, + author_steam_id: author, + message: "something awful", + message_created_at: new Date("2025-01-01T00:00:00.000Z"), + source: "web", + deleted_by_steam_id: mod.steam_id, + }, + ]); + }); + + it("still deletes a message whose author has no player row", async () => { + const mod = await moderator(); + const matchId = randomUUID(); + const target = await post(matchId, "76561199699999999"); + + await expect( + chat.deleteMessage(ChatLobbyType.Match, matchId, target, mod), + ).resolves.toEqual({ deleted: true }); + + const [audit] = await audits(); + + expect(audit.author_steam_id).toBeNull(); + expect(audit.message).toBe("something awful"); + }); + + it("finishes a delete whose redis removal failed, keeping the first audit", async () => { + const mod = await moderator(); + const earlier = await moderator(); + const author = await fx.player("Author"); + const matchId = randomUUID(); + const target = await post(matchId, author); + + await postgres.query( + `INSERT INTO chat_message_deletions + (message_id, room_type, room_id, author_steam_id, message, + deleted_by_steam_id) + VALUES ($1::uuid, 'match', $2, $3::bigint, 'something awful', + $4::bigint)`, + [target, matchId, author, earlier.steam_id], + ); + + await expect( + chat.deleteMessage(ChatLobbyType.Match, matchId, target, mod), + ).resolves.toEqual({ deleted: true }); + + expect(await redis.hexists(`chat_match_${matchId}`, target)).toBe(0); + + const rows = await audits(); + + expect(rows).toHaveLength(1); + expect(rows[0].deleted_by_steam_id).toBe(earlier.steam_id); + }); + + it("answers not_found once the message is gone", async () => { + const mod = await moderator(); + const author = await fx.player("Author"); + const matchId = randomUUID(); + const target = await post(matchId, author); + + await chat.deleteMessage(ChatLobbyType.Match, matchId, target, mod); + + await expect( + chat.deleteMessage(ChatLobbyType.Match, matchId, target, mod), + ).resolves.toEqual({ deleted: false, code: ChatErrorCode.NotFound }); + + expect(await audits()).toHaveLength(1); + }); + + it("keeps the audit when the author's player row goes", async () => { + const mod = await moderator(); + const author = await fx.player("Author"); + const matchId = randomUUID(); + const target = await post(matchId, author); + + await chat.deleteMessage(ChatLobbyType.Match, matchId, target, mod); + await postgres.query(`DELETE FROM players WHERE steam_id = $1::bigint`, [ + author, + ]); + + const [audit] = await audits(); + + expect(audit.author_steam_id).toBeNull(); + expect(audit.message).toBe("something awful"); + }); + }); + + describe("retracting the bell", () => { + const chatNotification = async ( + steamId: string, + entityId: string, + messageId: string, + type = "MatchChatMessage", + ) => { + const [row] = await postgres.query>( + `INSERT INTO notifications + (type, title, message, role, steam_id, entity_id, data) + VALUES ($4, 'Author', 'something awful', 'user', + $1::bigint, $2, + jsonb_build_object('threadKey', 'chat:' || $2, + 'messageId', $3::text)) + RETURNING id::text AS id`, + [steamId, entityId, messageId, type], + ); + return row.id; + }; + + // Match chat is off for push by default, so delivery is shown on a room + // whose category is on. + const subscribedReader = async () => { + const reader = await fx.player("Reader"); + await postgres.query( + `INSERT INTO push_subscriptions (steam_id, endpoint, p256dh, auth) + VALUES ($1::bigint, $2, 'key', 'auth')`, + [reader, `https://fcm.googleapis.com/fcm/send/${reader}`], + ); + return reader; + }; + + const deliver = async (id: string) => { + const push = pushService(); + await push.loadKeys(); + await push.sendForNotification({ id, type: "ChatMessage" }); + }; + + const deletedAt = async (id: string) => + ( + await postgres.query>( + `SELECT deleted_at FROM notifications WHERE id = $1::uuid`, + [id], + ) + ).at(0)?.deleted_at; + + it("retracts the deleted message's row and no other", async () => { + const mod = await moderator(); + const author = await fx.player("Author"); + const reader = await fx.player("Reader"); + const matchId = randomUUID(); + const target = await post(matchId, author); + const other = await post(matchId, author, "fine"); + + const retracted = await chatNotification( + reader, + `match:${matchId}`, + target, + ); + const kept = await chatNotification(reader, `match:${matchId}`, other); + + await chat.deleteMessage(ChatLobbyType.Match, matchId, target, mod); + + expect(await deletedAt(retracted)).toBeInstanceOf(Date); + expect(await deletedAt(kept)).toBeNull(); + }); + + it("drops a retracted row from push delivery", async () => { + const reader = await subscribedReader(); + const messageId = randomUUID(); + const id = await chatNotification( + reader, + "tournament:t-1", + messageId, + "ChatMessage", + ); + + await notifications.retractChatMessage( + "ChatMessage", + "tournament:t-1", + messageId, + ); + await deliver(id); + + expect(webPush.sendNotification).not.toHaveBeenCalled(); + }); + + it("still delivers the row next to it", async () => { + const reader = await subscribedReader(); + const retracted = randomUUID(); + await chatNotification( + reader, + "tournament:t-1", + retracted, + "ChatMessage", + ); + const kept = await chatNotification( + reader, + "tournament:t-1", + randomUUID(), + "ChatMessage", + ); + + await notifications.retractChatMessage( + "ChatMessage", + "tournament:t-1", + retracted, + ); + await deliver(kept); + + expect(webPush.sendNotification).toHaveBeenCalledTimes(1); + }); + }); + + describe("gag", () => { + const say = (steamId: string) => + chat.sendMessageToChat( + ChatLobbyType.Tournament, + "t-1", + { steam_id: steamId, name: "Someone", role: "user" } as any, + "hello", + true, + ); + + const sanction = ( + steamId: string, + type: string, + removeAt: string | null = null, + ) => + postgres.query( + `INSERT INTO player_sanctions + (player_steam_id, type, remove_sanction_date) + VALUES ($1::bigint, $2, $3::timestamptz)`, + [steamId, type, removeAt], + ); + + it.each(["gag", "silence"])("keeps a player under %s out", async (type) => { + const steamId = await fx.player(); + await sanction(steamId, type); + + await expect(say(steamId)).resolves.toEqual({ + accepted: false, + code: ChatErrorCode.Gagged, + }); + }); + + it("lets a player through once the gag has expired", async () => { + const steamId = await fx.player(); + await sanction( + steamId, + "gag", + new Date(Date.now() - 60_000).toISOString(), + ); + + await expect(say(steamId)).resolves.toMatchObject({ accepted: true }); + }); + + it("does not treat a voice mute as a gag", async () => { + const steamId = await fx.player(); + await sanction(steamId, "mute"); + + await expect(say(steamId)).resolves.toMatchObject({ accepted: true }); + }); + }); + + it("re-applies the migration cleanly", async () => { + await expect(postgres.query(up)).resolves.toBeDefined(); + await expect(postgres.query(up)).resolves.toBeDefined(); + }); +}); From 343449cee90bf9b02fabd98fe65d2bb226cd84b1 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 15:58:36 -0400 Subject: [PATCH 2/4] feature: chat moderation review fixes, name the action on chat:ack/chat:error - retract a deleted message's notifications by message id alone (new partial index), so a draft lobby's lines that moved into the match room are still retracted; the retracted text is blanked, since a recipient can read and restore their own deleted rows - re-check after writing a message's notifications: a delete that landed while they were being written had nothing to retract yet - organizers-room audit rows stay hidden from moderators (the room is match_organizer and up) - chat:ack and chat:error now carry action ("send" | "delete") so the web can tell a failed delete from a failed send --- .../tables/public_chat_message_deletions.yaml | 20 +++- .../down.sql | 1 + .../up.sql | 4 + src/chat/chat.gateway.spec.ts | 43 +++++-- src/chat/chat.gateway.ts | 16 ++- src/chat/chat.service.spec.ts | 70 ++++++++---- src/chat/chat.service.ts | 27 +++-- src/chat/types/ChatAction.ts | 3 + src/notifications/notifications.service.ts | 26 ++--- test/chat-direct-messages.spec.ts | 1 + test/chat-moderation.spec.ts | 107 +++++++++++++++--- 11 files changed, 239 insertions(+), 79 deletions(-) create mode 100644 src/chat/types/ChatAction.ts diff --git a/hasura/metadata/databases/default/tables/public_chat_message_deletions.yaml b/hasura/metadata/databases/default/tables/public_chat_message_deletions.yaml index 4d5ebdf4..310fe26c 100644 --- a/hasura/metadata/databases/default/tables/public_chat_message_deletions.yaml +++ b/hasura/metadata/databases/default/tables/public_chat_message_deletions.yaml @@ -9,7 +9,7 @@ object_relationships: using: foreign_key_constraint_on: deleted_by_steam_id select_permissions: - - role: moderator + - role: match_organizer permission: columns: - id @@ -25,3 +25,21 @@ select_permissions: filter: {} allow_aggregations: true comment: Evidence of moderated website chat. Written only by the API. + - role: moderator + permission: + columns: + - id + - message_id + - room_type + - room_id + - author_steam_id + - message + - message_created_at + - source + - deleted_by_steam_id + - deleted_at + filter: + room_type: + _neq: organizers + allow_aggregations: true + comment: The organizers' room is closed to moderators, and so is its evidence. diff --git a/hasura/migrations/default/1888000000100_chat_message_deletions/down.sql b/hasura/migrations/default/1888000000100_chat_message_deletions/down.sql index d66a5058..a7627784 100644 --- a/hasura/migrations/default/1888000000100_chat_message_deletions/down.sql +++ b/hasura/migrations/default/1888000000100_chat_message_deletions/down.sql @@ -1 +1,2 @@ +DROP INDEX IF EXISTS public.notifications_message_id_idx; DROP TABLE IF EXISTS public.chat_message_deletions; diff --git a/hasura/migrations/default/1888000000100_chat_message_deletions/up.sql b/hasura/migrations/default/1888000000100_chat_message_deletions/up.sql index 57ff9fc4..6f21201e 100644 --- a/hasura/migrations/default/1888000000100_chat_message_deletions/up.sql +++ b/hasura/migrations/default/1888000000100_chat_message_deletions/up.sql @@ -18,3 +18,7 @@ CREATE TABLE IF NOT EXISTS public.chat_message_deletions ( CREATE INDEX IF NOT EXISTS chat_message_deletions_author_idx ON public.chat_message_deletions (author_steam_id, deleted_at DESC); + +CREATE INDEX IF NOT EXISTS notifications_message_id_idx + ON public.notifications ((data->>'messageId')) + WHERE data->>'messageId' IS NOT NULL; diff --git a/src/chat/chat.gateway.spec.ts b/src/chat/chat.gateway.spec.ts index b56de6f7..730692d8 100644 --- a/src/chat/chat.gateway.spec.ts +++ b/src/chat/chat.gateway.spec.ts @@ -117,7 +117,12 @@ describe("ChatGateway lobby:chat", () => { expect(sent(socket)).toEqual([ { event: "chat:error", - data: { code: ChatErrorCode.TooLong, max: 2000, requestId: "r-1" }, + data: { + code: ChatErrorCode.TooLong, + action: "send", + max: 2000, + requestId: "r-1", + }, }, ]); }); @@ -135,7 +140,10 @@ describe("ChatGateway lobby:chat", () => { ); expect(sent(socket)).toEqual([ - { event: "chat:error", data: { code: "too_long", max: 2000 } }, + { + event: "chat:error", + data: { code: "too_long", action: "send", max: 2000 }, + }, ]); }); @@ -180,7 +188,11 @@ describe("ChatGateway lobby:chat", () => { expect(sent(socket)).toEqual([ { event: "chat:error", - data: { code: ChatErrorCode.NotAllowed, requestId: "r-2" }, + data: { + code: ChatErrorCode.NotAllowed, + action: "send", + requestId: "r-2", + }, }, ]); }); @@ -204,7 +216,10 @@ describe("ChatGateway lobby:chat", () => { expect(chat.sendChatToServer).not.toHaveBeenCalled(); expect(sent(socket)).toEqual([ - { event: "chat:error", data: { code: "gagged", requestId: "r-4" } }, + { + event: "chat:error", + data: { code: "gagged", action: "send", requestId: "r-4" }, + }, ]); }); @@ -283,7 +298,10 @@ describe("ChatGateway lobby:chat", () => { ); expect(sent(socket)).toEqual([ - { event: "chat:ack", data: { requestId: "r-3", messageId: "msg-1" } }, + { + event: "chat:ack", + data: { requestId: "r-3", messageId: "msg-1", action: "send" }, + }, ]); }); @@ -380,7 +398,10 @@ describe("ChatGateway lobby:delete", () => { ); expect(sent(socket)).toEqual([ - { event: "chat:ack", data: { requestId: "r-2", messageId: MESSAGE_ID } }, + { + event: "chat:ack", + data: { requestId: "r-2", messageId: MESSAGE_ID, action: "delete" }, + }, ]); }); @@ -412,7 +433,10 @@ describe("ChatGateway lobby:delete", () => { ); expect(sent(socket)).toEqual([ - { event: "chat:error", data: { code, requestId: "r-3" } }, + { + event: "chat:error", + data: { code, action: "delete", requestId: "r-3" }, + }, ]); }, ); @@ -430,7 +454,10 @@ describe("ChatGateway lobby:delete", () => { ); expect(sent(socket)).toEqual([ - { event: "chat:error", data: { code: "not_allowed" } }, + { + event: "chat:error", + data: { code: "not_allowed", action: "delete" }, + }, ]); }); }); diff --git a/src/chat/chat.gateway.ts b/src/chat/chat.gateway.ts index a51b2384..6a9e58c3 100644 --- a/src/chat/chat.gateway.ts +++ b/src/chat/chat.gateway.ts @@ -8,6 +8,7 @@ import { ChatService } from "./chat.service"; import { FiveStackWebSocketClient } from "src/sockets/types/FiveStackWebSocketClient"; import { ChatLobbyType } from "./enums/ChatLobbyTypes"; import { ChatErrorCode } from "./enums/ChatErrorCode"; +import { ChatAction } from "./types/ChatAction"; import { isRoleAbove } from "@utilities/isRoleAbove"; @WebSocketGateway({ @@ -112,7 +113,7 @@ export class ChatGateway { if ("error" in parsed) { if (parsed.error === ChatErrorCode.TooLong) { - this.sendError(client, parsed.error, requestId); + this.sendError(client, "send", parsed.error, requestId); } return; } @@ -129,13 +130,13 @@ export class ChatGateway { // any signed-in socket print into any live match. if (result.accepted === false) { if (result.code) { - this.sendError(client, result.code, requestId); + this.sendError(client, "send", result.code, requestId); } return; } if (requestId) { - this.sendAck(client, requestId, result.messageId); + this.sendAck(client, "send", requestId, result.messageId); } if (data.type !== ChatLobbyType.Match) { @@ -185,12 +186,12 @@ export class ChatGateway { ); if (result.deleted === false) { - this.sendError(client, result.code, requestId); + this.sendError(client, "delete", result.code, requestId); return; } if (requestId) { - this.sendAck(client, requestId, data.messageId); + this.sendAck(client, "delete", requestId, data.messageId); } } @@ -200,6 +201,7 @@ export class ChatGateway { private sendError( client: FiveStackWebSocketClient, + action: ChatAction, code: ChatErrorCode, requestId?: string, ) { @@ -208,6 +210,7 @@ export class ChatGateway { event: "chat:error", data: { code, + action, ...(code === ChatErrorCode.TooLong ? { max: ChatService.MAX_MESSAGE_LENGTH } : {}), @@ -219,13 +222,14 @@ export class ChatGateway { private sendAck( client: FiveStackWebSocketClient, + action: ChatAction, requestId: string, messageId: string, ) { client.send( JSON.stringify({ event: "chat:ack", - data: { requestId, messageId }, + data: { requestId, messageId, action }, }), ); } diff --git a/src/chat/chat.service.spec.ts b/src/chat/chat.service.spec.ts index badfe74b..76be7065 100644 --- a/src/chat/chat.service.spec.ts +++ b/src/chat/chat.service.spec.ts @@ -15,6 +15,7 @@ describe("ChatService direct messages", () => { hget: jest.fn().mockResolvedValue(null), hgetall: jest.fn().mockResolvedValue({}), hdel: jest.fn(), + hexists: jest.fn().mockResolvedValue(1), get: jest.fn().mockResolvedValue(null), set: jest.fn(), del: jest.fn(), @@ -249,6 +250,7 @@ describe("ChatService direct messages", () => { // room would otherwise leave them seated for every test after it. redis.hget.mockResolvedValue(null); redis.hgetall.mockResolvedValue({}); + redis.hexists.mockResolvedValue(1); redis.get.mockResolvedValue(null); notifications.retractChatMessage.mockResolvedValue(undefined); acceptedFriendships = [[ME, FRIEND]]; @@ -1107,29 +1109,7 @@ describe("ChatService direct messages", () => { moderator(), ); - expect(notifications.retractChatMessage).toHaveBeenCalledWith( - "MatchChatMessage", - "match:m-1", - MESSAGE_ID, - ); - }); - - it("retracts under the room's own notification type", async () => { - store(ChatLobbyType.Tournament, "t-1"); - tournament.roster = [ME]; - - await service.deleteMessage( - ChatLobbyType.Tournament, - "t-1", - MESSAGE_ID, - moderator(), - ); - - expect(notifications.retractChatMessage).toHaveBeenCalledWith( - "ChatMessage", - "tournament:t-1", - MESSAGE_ID, - ); + expect(notifications.retractChatMessage).toHaveBeenCalledWith(MESSAGE_ID); }); it("still deletes when the retraction fails", async () => { @@ -1237,6 +1217,50 @@ describe("ChatService direct messages", () => { expect(redis.hget).not.toHaveBeenCalled(); }); + describe("while its notifications are still being written", () => { + const sayInTournament = async () => { + tournament.roster = [ME, FRIEND]; + redis.hget.mockResolvedValue( + JSON.stringify({ user: { steam_id: ME } }), + ); + role = "user"; + + const result = await service.sendMessageToChat( + ChatLobbyType.Tournament, + "t-1", + { steam_id: ME, name: "Someone", role: "user" } as any, + "hi", + ); + + await flush(); + await flush(); + + return result.accepted ? result.messageId : undefined; + }; + + it("retracts them once written if the message was deleted meanwhile", async () => { + redis.hexists.mockResolvedValue(0); + + const messageId = await sayInTournament(); + + expect(notifications.notifyPlayers).toHaveBeenCalled(); + expect(redis.hexists).toHaveBeenCalledWith( + "chat_tournament_t-1", + messageId, + ); + expect(notifications.retractChatMessage).toHaveBeenCalledWith( + messageId, + ); + }); + + it("leaves them alone while the message is still there", async () => { + await sayInTournament(); + + expect(notifications.notifyPlayers).toHaveBeenCalled(); + expect(notifications.retractChatMessage).not.toHaveBeenCalled(); + }); + }); + it("stamps each chat notification with the message it announces", async () => { redis.hget.mockResolvedValue(JSON.stringify({ user: { steam_id: ME } })); role = "user"; diff --git a/src/chat/chat.service.ts b/src/chat/chat.service.ts index fcda19c9..b99adc4e 100644 --- a/src/chat/chat.service.ts +++ b/src/chat/chat.service.ts @@ -727,18 +727,12 @@ export class ChatService { void this.to(type, id, "deleted", { id: messageId }); - await this.notifications - .retractChatMessage( - ChatService.notificationTypeFor(type), - `${type}:${id}`, - messageId, - ) - .catch((error) => { - this.logger.warn( - `unable to retract notifications for ${type}:${id} message ${messageId}`, - error, - ); - }); + await this.notifications.retractChatMessage(messageId).catch((error) => { + this.logger.warn( + `unable to retract notifications for ${type}:${id} message ${messageId}`, + error, + ); + }); return { deleted: true }; } @@ -869,6 +863,15 @@ export class ChatService { entityId, targets, ); + + // A delete that landed while the rows above were being written retracted + // nothing, and it removes the message before it retracts. + if ( + type !== ChatLobbyType.Direct && + !(await this.redis.hexists(`chat_${type}_${id}`, messageId)) + ) { + await this.notifications.retractChatMessage(messageId); + } } // Match chat is its own notification type, and so its own push category. diff --git a/src/chat/types/ChatAction.ts b/src/chat/types/ChatAction.ts new file mode 100644 index 00000000..84411874 --- /dev/null +++ b/src/chat/types/ChatAction.ts @@ -0,0 +1,3 @@ +// Echoed on `chat:ack` and `chat:error` so the client knows which request the +// answer is for. A contract with the web -- add to it, never rename. +export type ChatAction = "send" | "delete"; diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts index de00b471..b3155a47 100644 --- a/src/notifications/notifications.service.ts +++ b/src/notifications/notifications.service.ts @@ -769,22 +769,22 @@ export class NotificationsService { ); } + // By message id alone: a draft lobby's history moves into the match room with + // its ids intact, while its rows keep the draft's type and entity. + // // Soft-deleting is also what stops a push still waiting in a bundling window, - // since chat delivery skips deleted rows. Rows collapseOlderUnread already - // retired stay retired, even when the one that superseded them goes. - async retractChatMessage( - type: e_notification_types_enum, - entityId: string, - messageId: string, - ) { + // since chat delivery skips deleted rows. The text goes too, because a + // recipient can read and restore their own deleted rows. Rows + // collapseOlderUnread already retired stay retired, even when the one that + // superseded them goes. + async retractChatMessage(messageId: string) { await this.postgres.query( `UPDATE public.notifications - SET deleted_at = now() - WHERE type = $1 - AND entity_id = $2 - AND data->>'messageId' = $3 - AND deleted_at IS NULL`, - [type, entityId, messageId], + SET deleted_at = COALESCE(deleted_at, now()), + message = '' + WHERE data->>'messageId' = $1 + AND (deleted_at IS NULL OR message <> '')`, + [messageId], ); } diff --git a/test/chat-direct-messages.spec.ts b/test/chat-direct-messages.spec.ts index 94869e44..a033ae28 100644 --- a/test/chat-direct-messages.spec.ts +++ b/test/chat-direct-messages.spec.ts @@ -22,6 +22,7 @@ describe("direct messages (SQL-driven)", () => { hget: jest.fn().mockResolvedValue(null), hgetall: jest.fn().mockResolvedValue({}), hdel: jest.fn(), + hexists: jest.fn().mockResolvedValue(1), get: jest.fn().mockResolvedValue(null), set: jest.fn(), del: jest.fn(), diff --git a/test/chat-moderation.spec.ts b/test/chat-moderation.spec.ts index 9f8d970c..a4caf95a 100644 --- a/test/chat-moderation.spec.ts +++ b/test/chat-moderation.spec.ts @@ -366,13 +366,17 @@ describe("chat moderation (SQL-driven)", () => { await push.sendForNotification({ id, type: "ChatMessage" }); }; - const deletedAt = async (id: string) => + const notification = async (id: string) => ( - await postgres.query>( - `SELECT deleted_at FROM notifications WHERE id = $1::uuid`, - [id], - ) - ).at(0)?.deleted_at; + await postgres.query< + Array<{ deleted_at: Date | null; message: string }> + >(`SELECT deleted_at, message FROM notifications WHERE id = $1::uuid`, [ + id, + ]) + ).at(0); + + const deletedAt = async (id: string) => + (await notification(id))?.deleted_at; it("retracts the deleted message's row and no other", async () => { const mod = await moderator(); @@ -395,6 +399,85 @@ describe("chat moderation (SQL-driven)", () => { expect(await deletedAt(kept)).toBeNull(); }); + it("takes the text out of the row, so it cannot be read back", async () => { + const mod = await moderator(); + const author = await fx.player("Author"); + const reader = await fx.player("Reader"); + const matchId = randomUUID(); + const target = await post(matchId, author); + const id = await chatNotification(reader, `match:${matchId}`, target); + + await chat.deleteMessage(ChatLobbyType.Match, matchId, target, mod); + + expect((await notification(id))?.message).toBe(""); + }); + + it("blanks a row the bell had already collapsed, leaving it retired", async () => { + const reader = await fx.player("Reader"); + const messageId = randomUUID(); + const id = await chatNotification(reader, "tournament:t-1", messageId); + await postgres.query( + `UPDATE notifications + SET deleted_at = now() - interval '1 hour' + WHERE id = $1::uuid`, + [id], + ); + const before = await deletedAt(id); + + await notifications.retractChatMessage(messageId); + + expect(await notification(id)).toEqual({ + deleted_at: before, + message: "", + }); + }); + + it("retracts a draft lobby's message after it moved into the match", async () => { + const mod = await moderator(); + const author = await fx.player("Author"); + const reader = await fx.player("Reader"); + const draftId = randomUUID(); + const matchId = randomUUID(); + const target = await post(draftId, author); + await redis.rename(`chat_match_${draftId}`, `chat_draft_${draftId}`); + + const id = await chatNotification( + reader, + `draft:${draftId}`, + target, + "ChatMessage", + ); + + await chat.migrateLobbyMessages( + ChatLobbyType.Draft, + draftId, + ChatLobbyType.Match, + matchId, + ); + + await expect( + chat.deleteMessage(ChatLobbyType.Match, matchId, target, mod), + ).resolves.toEqual({ deleted: true }); + + expect(await deletedAt(id)).toBeInstanceOf(Date); + }); + + it("finds the message's rows through an index", async () => { + const plan = await postgres.transaction(async (client) => { + await client.query("SET LOCAL enable_seqscan = off"); + + const { rows } = await client.query( + `EXPLAIN SELECT id FROM notifications + WHERE data->>'messageId' = $1`, + [randomUUID()], + ); + + return rows.map((row) => row["QUERY PLAN"]).join("\n"); + }); + + expect(plan).toContain("notifications_message_id_idx"); + }); + it("drops a retracted row from push delivery", async () => { const reader = await subscribedReader(); const messageId = randomUUID(); @@ -405,11 +488,7 @@ describe("chat moderation (SQL-driven)", () => { "ChatMessage", ); - await notifications.retractChatMessage( - "ChatMessage", - "tournament:t-1", - messageId, - ); + await notifications.retractChatMessage(messageId); await deliver(id); expect(webPush.sendNotification).not.toHaveBeenCalled(); @@ -431,11 +510,7 @@ describe("chat moderation (SQL-driven)", () => { "ChatMessage", ); - await notifications.retractChatMessage( - "ChatMessage", - "tournament:t-1", - retracted, - ); + await notifications.retractChatMessage(retracted); await deliver(kept); expect(webPush.sendNotification).toHaveBeenCalledTimes(1); From 5fbdda8adaafe2481522d989c518d93ba8cc785e Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 16:05:22 -0400 Subject: [PATCH 3/4] bug: re-check a message's deletion by its audit row, not its redis field The redis field is also gone when a message expires (a 0 TTL drops it at once) or when a draft lobby's history moves into the match, and treating either as a delete retracted every notification for a message nobody removed. The audit row is committed before a delete retracts, so it answers the same race without the false positives. The unique key leads with message_id so that lookup is indexed. Retraction is also scoped to the chat notification types. --- .../up.sql | 2 +- src/chat/chat.service.spec.ts | 21 ++++++++++++------- src/chat/chat.service.ts | 20 +++++++++++++----- src/notifications/notifications.service.ts | 1 + test/chat-direct-messages.spec.ts | 1 - test/chat-moderation.spec.ts | 14 +++++++++++++ 6 files changed, 44 insertions(+), 15 deletions(-) diff --git a/hasura/migrations/default/1888000000100_chat_message_deletions/up.sql b/hasura/migrations/default/1888000000100_chat_message_deletions/up.sql index 6f21201e..62df8365 100644 --- a/hasura/migrations/default/1888000000100_chat_message_deletions/up.sql +++ b/hasura/migrations/default/1888000000100_chat_message_deletions/up.sql @@ -13,7 +13,7 @@ CREATE TABLE IF NOT EXISTS public.chat_message_deletions ( deleted_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (id), - UNIQUE (room_type, room_id, message_id) + UNIQUE (message_id, room_type, room_id) ); CREATE INDEX IF NOT EXISTS chat_message_deletions_author_idx diff --git a/src/chat/chat.service.spec.ts b/src/chat/chat.service.spec.ts index 76be7065..196753cd 100644 --- a/src/chat/chat.service.spec.ts +++ b/src/chat/chat.service.spec.ts @@ -15,7 +15,6 @@ describe("ChatService direct messages", () => { hget: jest.fn().mockResolvedValue(null), hgetall: jest.fn().mockResolvedValue({}), hdel: jest.fn(), - hexists: jest.fn().mockResolvedValue(1), get: jest.fn().mockResolvedValue(null), set: jest.fn(), del: jest.fn(), @@ -38,6 +37,7 @@ describe("ChatService direct messages", () => { let role: string; let queries: Array<{ sql: string; bindings: any[] }>; let gagged: boolean; + let audited: boolean; const postgres = { query: jest.fn(async (sql: string, bindings: any[]): Promise => { queries.push({ sql, bindings }); @@ -46,6 +46,10 @@ describe("ChatService direct messages", () => { return [{ gagged }]; } + if (sql.includes("SELECT 1 FROM public.chat_message_deletions")) { + return [{ deleted: audited }]; + } + return []; }), }; @@ -250,7 +254,6 @@ describe("ChatService direct messages", () => { // room would otherwise leave them seated for every test after it. redis.hget.mockResolvedValue(null); redis.hgetall.mockResolvedValue({}); - redis.hexists.mockResolvedValue(1); redis.get.mockResolvedValue(null); notifications.retractChatMessage.mockResolvedValue(undefined); acceptedFriendships = [[ME, FRIEND]]; @@ -266,6 +269,7 @@ describe("ChatService direct messages", () => { role = "user"; queries = []; gagged = false; + audited = false; rcon.send.mockResolvedValue(undefined); rcon.connect.mockResolvedValue(rcon); @@ -1239,21 +1243,22 @@ describe("ChatService direct messages", () => { }; it("retracts them once written if the message was deleted meanwhile", async () => { - redis.hexists.mockResolvedValue(0); + audited = true; const messageId = await sayInTournament(); expect(notifications.notifyPlayers).toHaveBeenCalled(); - expect(redis.hexists).toHaveBeenCalledWith( - "chat_tournament_t-1", - messageId, - ); + expect( + queries.find(({ sql }) => + sql.includes("SELECT 1 FROM public.chat_message_deletions"), + )?.bindings, + ).toEqual([messageId]); expect(notifications.retractChatMessage).toHaveBeenCalledWith( messageId, ); }); - it("leaves them alone while the message is still there", async () => { + it("leaves them alone when nothing deleted the message", async () => { await sayInTournament(); expect(notifications.notifyPlayers).toHaveBeenCalled(); diff --git a/src/chat/chat.service.ts b/src/chat/chat.service.ts index b99adc4e..75d3b01a 100644 --- a/src/chat/chat.service.ts +++ b/src/chat/chat.service.ts @@ -865,15 +865,25 @@ export class ChatService { ); // A delete that landed while the rows above were being written retracted - // nothing, and it removes the message before it retracts. - if ( - type !== ChatLobbyType.Direct && - !(await this.redis.hexists(`chat_${type}_${id}`, messageId)) - ) { + // nothing. Its audit row is committed before it retracts, and unlike the + // redis field it is not gone just because the message expired or moved. + if (type !== ChatLobbyType.Direct && (await this.wasDeleted(messageId))) { await this.notifications.retractChatMessage(messageId); } } + private async wasDeleted(messageId: string): Promise { + const [row] = await this.postgres.query>( + `SELECT EXISTS ( + SELECT 1 FROM public.chat_message_deletions + WHERE message_id = $1::uuid + ) AS deleted`, + [messageId], + ); + + return row?.deleted === true; + } + // Match chat is its own notification type, and so its own push category. // // Every line typed in-game is relayed into the match room by diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts index b3155a47..f54f0466 100644 --- a/src/notifications/notifications.service.ts +++ b/src/notifications/notifications.service.ts @@ -783,6 +783,7 @@ export class NotificationsService { SET deleted_at = COALESCE(deleted_at, now()), message = '' WHERE data->>'messageId' = $1 + AND type IN ('ChatMessage', 'MatchChatMessage') AND (deleted_at IS NULL OR message <> '')`, [messageId], ); diff --git a/test/chat-direct-messages.spec.ts b/test/chat-direct-messages.spec.ts index a033ae28..94869e44 100644 --- a/test/chat-direct-messages.spec.ts +++ b/test/chat-direct-messages.spec.ts @@ -22,7 +22,6 @@ describe("direct messages (SQL-driven)", () => { hget: jest.fn().mockResolvedValue(null), hgetall: jest.fn().mockResolvedValue({}), hdel: jest.fn(), - hexists: jest.fn().mockResolvedValue(1), get: jest.fn().mockResolvedValue(null), set: jest.fn(), del: jest.fn(), diff --git a/test/chat-moderation.spec.ts b/test/chat-moderation.spec.ts index a4caf95a..e4928354 100644 --- a/test/chat-moderation.spec.ts +++ b/test/chat-moderation.spec.ts @@ -310,6 +310,20 @@ describe("chat moderation (SQL-driven)", () => { expect(await audits()).toHaveLength(1); }); + it("tells a deleted message from one that merely expired", async () => { + const mod = await moderator(); + const author = await fx.player("Author"); + const matchId = randomUUID(); + const deleted = await post(matchId, author); + const expired = await post(matchId, author, "fine"); + + await chat.deleteMessage(ChatLobbyType.Match, matchId, deleted, mod); + await redis.hdel(`chat_match_${matchId}`, expired); + + expect(await chat["wasDeleted"](deleted)).toBe(true); + expect(await chat["wasDeleted"](expired)).toBe(false); + }); + it("keeps the audit when the author's player row goes", async () => { const mod = await moderator(); const author = await fx.player("Author"); From af7a1ca8d2edd770970df649b81b6450e84c7767 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 20:26:05 -0400 Subject: [PATCH 4/4] test: prove a deleted message's re-check and retraction scope are fixed The post-write re-check once read a missing redis field as a delete, which also retracts the rows of a message that merely expired or moved with its draft lobby. The retraction is scoped to the chat notification types. --- test/chat-moderation.spec.ts | 53 ++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/test/chat-moderation.spec.ts b/test/chat-moderation.spec.ts index e4928354..0af53e13 100644 --- a/test/chat-moderation.spec.ts +++ b/test/chat-moderation.spec.ts @@ -476,6 +476,59 @@ describe("chat moderation (SQL-driven)", () => { expect(await deletedAt(id)).toBeInstanceOf(Date); }); + it("leaves a message's rows alone when it expired rather than being deleted", async () => { + // A 0 TTL drops the field as soon as it is written, and a draft lobby's + // history moves out from under it into the match. Neither is a delete. + const author = await fx.player("Author"); + const reader = await fx.player("Reader"); + const matchId = randomUUID(); + const messageId = await post(matchId, author); + const id = await chatNotification(reader, `match:${matchId}`, messageId); + await redis.hdel(`chat_match_${matchId}`, messageId); + + const members = jest + .spyOn(chat, "getLobbyMemberSteamIds") + .mockResolvedValueOnce([author, reader]); + const written = jest + .spyOn(notifications, "notifyPlayers") + .mockResolvedValueOnce(undefined); + + await chat["notifyLobbyMembers"]( + ChatLobbyType.Match, + matchId, + { steam_id: author, name: "Author", role: "user" } as any, + "Author", + "something awful", + messageId, + ); + + members.mockRestore(); + written.mockRestore(); + + expect(await notification(id)).toEqual({ + deleted_at: null, + message: "something awful", + }); + }); + + it("leaves a notification that is not chat alone, whatever its data holds", async () => { + const reader = await fx.player("Reader"); + const messageId = randomUUID(); + const id = await chatNotification( + reader, + "tournament:t-1", + messageId, + "MatchStatusChange", + ); + + await notifications.retractChatMessage(messageId); + + expect(await notification(id)).toEqual({ + deleted_at: null, + message: "something awful", + }); + }); + it("finds the message's rows through an index", async () => { const plan = await postgres.transaction(async (client) => { await client.query("SET LOCAL enable_seqscan = off");