Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ select_permissions:
columns:
- auto_start
- created_at
- finished_at
- description
- homepage
- id
Expand Down Expand Up @@ -382,6 +383,7 @@ select_permissions:
columns:
- auto_start
- created_at
- finished_at
- description
- discord_guild_id
- discord_notifications_enabled
Expand Down Expand Up @@ -459,6 +461,7 @@ select_permissions:
columns:
- auto_start
- created_at
- finished_at
- description
- homepage
- id
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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';
7 changes: 7 additions & 0 deletions hasura/triggers/tournaments.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions src/chat/chat.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
| {
Expand Down Expand Up @@ -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 }];
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 = () =>
Expand All @@ -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" }];
Expand Down
25 changes: 23 additions & 2 deletions src/chat/chat.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
]);

Expand Down Expand Up @@ -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[] =
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<boolean> {
const [row] = await this.postgres.query<Array<{ chat_open: boolean }>>(
`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
Expand Down Expand Up @@ -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")) {
Expand Down
54 changes: 54 additions & 0 deletions src/chat/tournament-chat-retention.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading