From c98fc590f4588aff2ed0652f1c72ab8e8c02e4cb Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 2 Oct 2026 16:46:18 -0400 Subject: [PATCH] feature: finished tournament chat stays open 7 days --- .../default/tables/public_tournaments.yaml | 3 + .../down.sql | 7 + .../up.sql | 21 ++ hasura/triggers/tournaments.sql | 7 + src/chat/chat.service.spec.ts | 39 ++ src/chat/chat.service.ts | 25 +- src/chat/tournament-chat-retention.spec.ts | 54 +++ test/tournament-chat-window.spec.ts | 338 ++++++++++++++++++ 8 files changed, 492 insertions(+), 2 deletions(-) create mode 100644 hasura/migrations/default/1890000000310_tournament_chat_seven_days/down.sql create mode 100644 hasura/migrations/default/1890000000310_tournament_chat_seven_days/up.sql create mode 100644 src/chat/tournament-chat-retention.spec.ts create mode 100644 test/tournament-chat-window.spec.ts diff --git a/hasura/metadata/databases/default/tables/public_tournaments.yaml b/hasura/metadata/databases/default/tables/public_tournaments.yaml index 50afc232..90651043 100644 --- a/hasura/metadata/databases/default/tables/public_tournaments.yaml +++ b/hasura/metadata/databases/default/tables/public_tournaments.yaml @@ -325,6 +325,7 @@ select_permissions: columns: - auto_start - created_at + - finished_at - description - homepage - id @@ -382,6 +383,7 @@ select_permissions: columns: - auto_start - created_at + - finished_at - description - discord_guild_id - discord_notifications_enabled @@ -459,6 +461,7 @@ select_permissions: columns: - auto_start - created_at + - finished_at - description - homepage - id diff --git a/hasura/migrations/default/1890000000310_tournament_chat_seven_days/down.sql b/hasura/migrations/default/1890000000310_tournament_chat_seven_days/down.sql new file mode 100644 index 00000000..8ee476b0 --- /dev/null +++ b/hasura/migrations/default/1890000000310_tournament_chat_seven_days/down.sql @@ -0,0 +1,7 @@ +UPDATE public.settings + SET value = '86400' + WHERE name = 'public.chat_ttl_tournament' + AND value = '604800'; + +ALTER TABLE public.tournaments + DROP COLUMN IF EXISTS finished_at; diff --git a/hasura/migrations/default/1890000000310_tournament_chat_seven_days/up.sql b/hasura/migrations/default/1890000000310_tournament_chat_seven_days/up.sql new file mode 100644 index 00000000..24d62ef5 --- /dev/null +++ b/hasura/migrations/default/1890000000310_tournament_chat_seven_days/up.sql @@ -0,0 +1,21 @@ +ALTER TABLE public.tournaments + ADD COLUMN IF NOT EXISTS finished_at timestamptz; + +UPDATE public.tournaments t + SET finished_at = last_match.ended_at + FROM ( + SELECT ts.tournament_id, max(m.ended_at) AS ended_at + FROM public.tournament_stages ts + JOIN public.tournament_brackets tb ON tb.tournament_stage_id = ts.id + JOIN public.matches m ON m.id = tb.match_id + GROUP BY ts.tournament_id + ) last_match + WHERE last_match.tournament_id = t.id + AND t.status = 'Finished' + AND t.finished_at IS NULL; + +-- 86400 is what 1877000006000 seeded; any other value is an operator's choice. +UPDATE public.settings + SET value = '604800' + WHERE name = 'public.chat_ttl_tournament' + AND value = '86400'; diff --git a/hasura/triggers/tournaments.sql b/hasura/triggers/tournaments.sql index b867be95..0846699b 100644 --- a/hasura/triggers/tournaments.sql +++ b/hasura/triggers/tournaments.sql @@ -231,6 +231,9 @@ BEGIN ELSE -- No action needed for other status changes END CASE; + + -- After the CASE, which can turn a start into CancelledMinTeams. + NEW.finished_at := CASE WHEN NEW.status = 'Finished' THEN now() END; END IF; RETURN NEW; @@ -301,6 +304,10 @@ CREATE OR REPLACE FUNCTION public.tbi_tournaments() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN + IF NEW.status = 'Finished' AND NEW.finished_at IS NULL THEN + NEW.finished_at := now(); + END IF; + IF NEW.discord_notifications_enabled IS NULL THEN IF EXISTS ( SELECT 1 diff --git a/src/chat/chat.service.spec.ts b/src/chat/chat.service.spec.ts index 4e204627..920d7408 100644 --- a/src/chat/chat.service.spec.ts +++ b/src/chat/chat.service.spec.ts @@ -47,6 +47,8 @@ describe("ChatService direct messages", () => { // What a block committed between a send's access check and its insert does // to that insert. let dmInsertBlocked: boolean; + // Whether the tournament's room is still open, as the database judges it. + let tournamentChatOpen: boolean; // The one direct message the fake database holds, if a test put one there. let directMessage: | { @@ -74,6 +76,10 @@ describe("ChatService direct messages", () => { return [{ gagged }]; } + if (sql.includes("AS chat_open")) { + return [{ chat_open: tournamentChatOpen }]; + } + if (sql.includes("SELECT 1 FROM public.chat_message_deletions")) { return [{ deleted: audited }]; } @@ -444,6 +450,7 @@ describe("ChatService direct messages", () => { directReactions = null; directReactionFailure = undefined; dmInsertBlocked = false; + tournamentChatOpen = true; blocks = []; rcon.send.mockResolvedValue(undefined); rcon.connect.mockResolvedValue(rcon); @@ -580,6 +587,23 @@ describe("ChatService direct messages", () => { expect(await join(ME)).toBe(false); }); + it("keeps a rostered player out once the room has closed", async () => { + tournament.roster = [ME]; + tournamentChatOpen = false; + + expect(await join(ME)).toBe(false); + }); + + it("asks the database whether a finished tournament's room is still open", async () => { + tournament.roster = [ME]; + + await join(ME); + + expect( + queries.find(({ sql }) => sql.includes("AS chat_open"))?.bindings, + ).toEqual(["t-1", ChatService.FINISHED_TOURNAMENT_CHAT_DAYS]); + }); + // the message write is the awaited step; the broadcast after it is // deliberately fire-and-forget const posted = () => @@ -601,6 +625,21 @@ describe("ChatService direct messages", () => { expect(posted()).toBe(false); }); + it("stops posting once the room has closed", async () => { + redis.hget.mockResolvedValue(JSON.stringify({ steam_id: ME })); + tournament.roster = [ME]; + tournamentChatOpen = false; + + await service.sendMessageToChat( + ChatLobbyType.Tournament, + "t-1", + { steam_id: ME, name: "Someone", role } as any, + "one more thing", + ); + + expect(posted()).toBe(false); + }); + it("lets a registered free agent post", async () => { redis.hget.mockResolvedValue(JSON.stringify({ steam_id: ME })); tournament.freeAgents = [{ steam_id: ME, status: "registered" }]; diff --git a/src/chat/chat.service.ts b/src/chat/chat.service.ts index 1ea33234..a5fd4525 100644 --- a/src/chat/chat.service.ts +++ b/src/chat/chat.service.ts @@ -43,7 +43,7 @@ export class ChatService { [ChatLobbyType.MatchTeam, 60 * 60], [ChatLobbyType.MatchMaking, 60 * 60], [ChatLobbyType.Draft, 60 * 60], - [ChatLobbyType.Tournament, 60 * 60 * 24], + [ChatLobbyType.Tournament, 60 * 60 * 24 * 7], [ChatLobbyType.Organizer, 60 * 60 * 24], ]); @@ -247,6 +247,9 @@ export class ChatService { AND from_steam_id = $3::bigint AND created_at > now() - make_interval(secs => $4::int)`; + // The web keeps the room's tab open for the same window. + public static readonly FINISHED_TOURNAMENT_CHAT_DAYS = 7; + // A drafted free agent is on a roster and gets in that way; withdrawn means // they left the pool. private static readonly TOURNAMENT_CHAT_FREE_AGENT_STATUSES: e_tournament_free_agent_statuses_enum[] = @@ -283,7 +286,7 @@ export class ChatService { { setting: SystemSettingName.ChatTtlTournament, type: ChatLobbyType.Tournament, - fallback: 60 * 60 * 24, + fallback: 60 * 60 * 24 * 7, }, { setting: SystemSettingName.ChatTtlOrganizers, @@ -378,6 +381,20 @@ export class ChatService { ); } + // Judged on the database's clock, the one finished_at was stamped with. A + // tournament finished before finished_at existed has none and stays closed. + private async isTournamentChatOpen(id: string): Promise { + const [row] = await this.postgres.query>( + `SELECT (status <> 'Finished' + OR finished_at > now() - make_interval(days => $2::int)) AS chat_open + FROM public.tournaments + WHERE id = $1::uuid`, + [id, ChatService.FINISHED_TOURNAMENT_CHAT_DAYS], + ); + + return row?.chat_open === true; + } + // Who is allowed in a room at all. // // Its own method because joining is no longer the only way in: marking a @@ -530,6 +547,10 @@ export class ChatService { if (tournaments.length === 0) { return false; } + + if (!(await this.isTournamentChatOpen(id))) { + return false; + } break; case ChatLobbyType.Draft: { if (isRoleAbove(user.role, "match_organizer")) { diff --git a/src/chat/tournament-chat-retention.spec.ts b/src/chat/tournament-chat-retention.spec.ts new file mode 100644 index 00000000..3fb46b35 --- /dev/null +++ b/src/chat/tournament-chat-retention.spec.ts @@ -0,0 +1,54 @@ +import { ChatService } from "./chat.service"; +import { ChatLobbyType } from "./enums/ChatLobbyTypes"; +import { SystemSettingName } from "../system/enums/SystemSettingName"; + +const SEVEN_DAYS = 60 * 60 * 24 * 7; + +describe("tournament chat retention", () => { + const fallback = (setting: SystemSettingName) => + ChatService.TTL_SETTINGS.find((entry) => entry.setting === setting) + ?.fallback; + + it("keeps tournament chat seven days unless an operator says otherwise", () => { + expect(fallback(SystemSettingName.ChatTtlTournament)).toBe(SEVEN_DAYS); + }); + + it("leaves the organizers' room default alone", () => { + expect(fallback(SystemSettingName.ChatTtlOrganizers)).toBe(60 * 60 * 24); + }); + + it("stores a tournament line for seven days before any setting has loaded", async () => { + const redis = { + get: jest.fn().mockResolvedValue(null), + hset: jest.fn(), + sendCommand: jest.fn(), + smembers: jest.fn().mockResolvedValue([]), + publish: jest.fn(), + }; + + const service = new ChatService( + { log: jest.fn(), warn: jest.fn(), error: jest.fn() } as any, + {} as any, + { query: jest.fn().mockResolvedValue({}) } as any, + { query: jest.fn().mockResolvedValue([]) } as any, + { getConnection: () => redis } as any, + { sendChatMessage: jest.fn() } as any, + { blockedAmong: jest.fn(async () => new Map()) } as any, + ); + + await service.sendMessageToChat( + ChatLobbyType.Tournament, + "11111111-1111-4111-8111-111111111111", + { steam_id: "76561198000000001", name: "p", role: "user" } as any, + "gg", + true, + "game", + ); + + const hexpire = redis.sendCommand.mock.calls + .map(([command]) => command) + .find((command: any) => command.name === "HEXPIRE"); + + expect(Number(hexpire.args[1])).toBe(SEVEN_DAYS); + }); +}); diff --git a/test/tournament-chat-window.spec.ts b/test/tournament-chat-window.spec.ts new file mode 100644 index 00000000..31b51963 --- /dev/null +++ b/test/tournament-chat-window.spec.ts @@ -0,0 +1,338 @@ +import { readFileSync } from "fs"; +import { join } from "path"; +import IORedis, { Redis } from "ioredis"; +import { GenericContainer, StartedTestContainer } from "testcontainers"; +import { PostgresService } from "./../src/postgres/postgres.service"; +import { Fixtures } from "./utils/fixtures"; +import { bootMigratedDb, runAsUser, SqlTestDb } from "./utils/sql-test-db"; +import { ChatService } from "./../src/chat/chat.service"; +import { PlayerBlocksService } from "./../src/player-blocks/player-blocks.service"; +import { ChatErrorCode } from "./../src/chat/enums/ChatErrorCode"; +import { ChatLobbyType } from "./../src/chat/enums/ChatLobbyTypes"; + +const REGION = "TestChatWindow"; + +// Who belongs in the room is chat.service.spec's subject; this is about when a +// finished tournament's room closes, which the database decides. +describe("finished tournament chat (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let fx: Fixtures; + let container: StartedTestContainer; + let redis: Redis; + let chat: ChatService; + + const logger = { log: jest.fn(), warn: jest.fn(), error: jest.fn() }; + + 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 }; + } + + if (query.tournaments) { + return { tournaments: [{ id: query.tournaments.__args.where.id._eq }] }; + } + + return {}; + }), + }; + + 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("TournamentChatWindowTest"); + postgres = db.postgres; + fx = new Fixtures(postgres, 76561199620000000n); + await fx.region(REGION); + + chat = new ChatService( + logger as any, + {} as any, + hasura as any, + postgres, + { getConnection: () => redis } as any, + { sendChatMessage: jest.fn(async () => {}) } as any, + new PlayerBlocksService(postgres), + ); + }, 600_000); + + afterAll(async () => { + redis?.disconnect(); + await container?.stop(); + await db?.stop(); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await redis.flushall(); + await postgres.query("DELETE FROM matches"); + await postgres.query("DELETE FROM tournaments"); + await postgres.query("DELETE FROM match_options"); + await postgres.query("DELETE FROM players"); + }); + + const liveTournament = async (status = "Live") => { + const organizer = await fx.player(); + const [options] = await postgres.query>( + `INSERT INTO match_options (mr, best_of, type, map_pool_id, map_veto, region_veto, regions) + SELECT 8, 1, 'Wingman', id, false, true, $1 + FROM map_pools WHERE type = 'Wingman' AND seed = true RETURNING id`, + [`{${REGION}}`], + ); + const [tournament] = await postgres.query>( + `INSERT INTO tournaments (name, start, organizer_steam_id, match_options_id, status) + VALUES ($1, now() - interval '1 day', $2, $3, $4) RETURNING id`, + [fx.nextName("cup"), organizer, options.id, status], + ); + return { id: tournament.id, organizer }; + }; + + const finish = async (id: string, organizer: string) => + runAsUser(postgres, organizer, "administrator", (query) => + query("UPDATE tournaments SET status = 'Finished' WHERE id = $1", [id]), + ); + + const finishedAt = async (id: string) => + ( + await postgres.query>( + "SELECT finished_at FROM tournaments WHERE id = $1", + [id], + ) + )[0].finished_at; + + const age = (id: string, interval: string) => + postgres.query( + `UPDATE tournaments SET finished_at = now() - $2::interval WHERE id = $1`, + [id, interval], + ); + + const member = async () => { + const steamId = await fx.player("Member"); + return { steam_id: steamId, name: "Member", role: "user" } as any; + }; + + const client = (user: any) => + ({ + id: `client-${user.steam_id}`, + user, + send: jest.fn(), + on: jest.fn(), + }) as any; + + const joins = async (tournamentId: string, user: any) => { + const socket = client(user); + await chat.joinMatchLobby(socket, ChatLobbyType.Tournament, tournamentId); + return socket.send.mock.calls.some(([payload]: [string]) => + JSON.parse(payload).event.endsWith(":messages"), + ); + }; + + const sends = (tournamentId: string, user: any) => + chat.sendMessageToChat( + ChatLobbyType.Tournament, + tournamentId, + user, + "gg everyone", + ); + + describe("finished_at", () => { + it("is stamped the moment the tournament finishes", async () => { + const { id, organizer } = await liveTournament(); + + expect(await finishedAt(id)).toBeNull(); + + const before = Date.now(); + await finish(id, organizer); + + const stamped = await finishedAt(id); + expect(stamped).not.toBeNull(); + expect(stamped!.getTime()).toBeGreaterThanOrEqual(before - 5_000); + expect(stamped!.getTime()).toBeLessThanOrEqual(Date.now() + 5_000); + }); + + it("is left alone by an update that does not change the status", async () => { + const { id, organizer } = await liveTournament(); + await finish(id, organizer); + await age(id, "2 days"); + const stamped = await finishedAt(id); + + await postgres.query( + "UPDATE tournaments SET description = 'recap' WHERE id = $1", + [id], + ); + + expect(await finishedAt(id)).toEqual(stamped); + }); + + it("is filled in from the last match for a tournament finished before it existed", async () => { + const { id, organizer } = await liveTournament("Setup"); + const [stage] = await postgres.query>( + `INSERT INTO tournament_stages (tournament_id, type, "order", min_teams, max_teams) + VALUES ($1, 'SingleElimination', 1, 4, 8) RETURNING id`, + [id], + ); + for (const endedAt of ["2026-09-20T18:00:00Z", "2026-09-21T20:30:00Z"]) { + const { matchId } = await fx.bareMatch(endedAt); + await postgres.query( + `INSERT INTO tournament_brackets (tournament_stage_id, match_id, round) + VALUES ($1, $2, 1)`, + [stage.id, matchId], + ); + } + await finish(id, organizer); + await postgres.query( + "UPDATE tournaments SET finished_at = NULL WHERE id = $1", + [id], + ); + + await postgres.query( + readFileSync( + join( + __dirname, + "../hasura/migrations/default/1890000000310_tournament_chat_seven_days/up.sql", + ), + "utf8", + ), + ); + + expect(await finishedAt(id)).toEqual(new Date("2026-09-21T20:30:00Z")); + }); + + it("is cleared when the tournament is no longer finished", async () => { + const { id, organizer } = await liveTournament(); + await finish(id, organizer); + + await runAsUser(postgres, organizer, "administrator", (query) => + query("UPDATE tournaments SET status = 'Live' WHERE id = $1", [id]), + ); + + expect(await finishedAt(id)).toBeNull(); + }); + }); + + describe("the room", () => { + it("stays open and writable for seven days after it finishes", async () => { + const { id, organizer } = await liveTournament(); + const user = await member(); + await finish(id, organizer); + await age(id, "6 days 23 hours"); + + expect(await joins(id, user)).toBe(true); + expect(await sends(id, user)).toMatchObject({ accepted: true }); + }); + + it("closes once the seven days are up", async () => { + const { id, organizer } = await liveTournament(); + const user = await member(); + await finish(id, organizer); + + expect(await joins(id, user)).toBe(true); + + await age(id, "7 days 1 minute"); + + expect(await sends(id, user)).toEqual({ + accepted: false, + code: ChatErrorCode.NotAllowed, + }); + expect(await joins(id, user)).toBe(false); + }); + + it("is closed for a tournament that finished before it was stamped", async () => { + const { id, organizer } = await liveTournament(); + const user = await member(); + await finish(id, organizer); + await postgres.query( + "UPDATE tournaments SET finished_at = NULL WHERE id = $1", + [id], + ); + + expect(await joins(id, user)).toBe(false); + }); + + it("is open while the tournament is live", async () => { + const { id } = await liveTournament(); + const user = await member(); + + expect(await joins(id, user)).toBe(true); + expect(await sends(id, user)).toMatchObject({ accepted: true }); + }); + }); +}); + +describe("tournament chat retention default", () => { + let db: SqlTestDb; + + const migration = join( + __dirname, + "../hasura/migrations/default/1890000000310_tournament_chat_seven_days", + ); + const up = () => readFileSync(join(migration, "up.sql"), "utf8"); + const down = () => readFileSync(join(migration, "down.sql"), "utf8"); + + const set = (value: string) => + db.postgres.query( + `INSERT INTO settings (name, value) VALUES ('public.chat_ttl_tournament', $1) + ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value`, + [value], + ); + + const value = async (name = "public.chat_ttl_tournament") => + ( + await db.postgres.query>( + "SELECT value FROM settings WHERE name = $1", + [name], + ) + )[0]?.value; + + beforeAll(async () => { + db = await bootMigratedDb("TournamentChatRetentionTest"); + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + it("keeps tournament chat seven days on a fresh install", async () => { + expect(await value()).toBe("604800"); + expect(await value("public.chat_ttl_organizers")).toBe("86400"); + }); + + it("moves a stack still on the old one day seed", async () => { + await set("86400"); + + await db.postgres.query(up()); + + expect(await value()).toBe("604800"); + }); + + it("leaves an operator's own value alone", async () => { + await set("172800"); + + await db.postgres.query(up()); + + expect(await value()).toBe("172800"); + }); + + it("rolls back to the one day seed", async () => { + await set("604800"); + + await db.postgres.query(down()); + + expect(await value()).toBe("86400"); + await db.postgres.query(up()); + }); +});