From e22b2c9beb02ae20e2f5f024284d088a2a1f3aed Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:00:46 +0200 Subject: [PATCH] Give every lobby set fresh team chats --- app/db/tables.ts | 6 +- app/features/chat/ChatRepository.server.ts | 10 +-- .../LobbyMatchRepository.server.test.ts | 39 ++++++++- .../lobbies/LobbyMatchRepository.server.ts | 71 ++++++++++------ .../lobbies/LobbyRepository.server.ts | 39 ++++----- .../lobbies/loaders/lobbies.$id.server.ts | 4 +- app/features/lobbies/lobbies-utils.ts | 7 ++ app/features/lobbies/routes/lobbies.$id.tsx | 4 +- .../map-list-generator/core/MapList.test.ts | 4 +- app/features/voice/voice-hooks.ts | 10 ++- e2e/lobbies.spec.ts | 82 ++++++++++++++++++- ...0261027183901-lobby-set-team-chat-rooms.ts | 64 +++++++++++++++ 12 files changed, 267 insertions(+), 73 deletions(-) create mode 100644 migrations/20261027183901-lobby-set-team-chat-rooms.ts diff --git a/app/db/tables.ts b/app/db/tables.ts index 7f38e8d2f..c33693330 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -1426,9 +1426,6 @@ export interface Lobby { roomSize: LobbyRoomSize; /** Common chat used while pending and by spectators */ chatRoomId: number | null; - /** Team chat of the alpha side of the current set */ - alphaChatRoomId: number | null; - bravoChatRoomId: number | null; /** Marker for the check-in opened notification having been sent */ checkInNotifiedAt: number | null; /** Since when the started lobby has had no set being played, `null` while one is (or before the start) */ @@ -1470,6 +1467,9 @@ export interface LobbySet { disputedAt: number | null; confirmedAt: number | null; confirmedByUserId: number | null; + /** Team chat of the set's alpha side */ + alphaChatRoomId: number | null; + bravoChatRoomId: number | null; createdAt: Generated; } diff --git a/app/features/chat/ChatRepository.server.ts b/app/features/chat/ChatRepository.server.ts index bb4bcfd63..d0d4a5d76 100644 --- a/app/features/chat/ChatRepository.server.ts +++ b/app/features/chat/ChatRepository.server.ts @@ -153,8 +153,8 @@ export async function findAllOpenRoomIdsByUserId( eb .case() .when("LobbySetParticipant.side", "=", "ALPHA") - .then(eb.ref("Lobby.alphaChatRoomId")) - .else(eb.ref("Lobby.bravoChatRoomId")) + .then(eb.ref("LobbySet.alphaChatRoomId")) + .else(eb.ref("LobbySet.bravoChatRoomId")) .end() .as("id"), ) @@ -452,8 +452,8 @@ type ChatRoomOwnerColumn = | "TournamentTeam.chatRoomId" | "ScrimPost.chatRoomId" | "Lobby.chatRoomId" - | "Lobby.alphaChatRoomId" - | "Lobby.bravoChatRoomId"; + | "LobbySet.alphaChatRoomId" + | "LobbySet.bravoChatRoomId"; /** The one table that can own a room of each type, and the columns of it that point to the room. */ const OWNER_COLUMNS_BY_ROOM_TYPE = { @@ -463,7 +463,7 @@ const OWNER_COLUMNS_BY_ROOM_TYPE = { TOURNAMENT_TEAM: ["TournamentTeam.chatRoomId"], SCRIM: ["ScrimPost.chatRoomId"], LOBBY: ["Lobby.chatRoomId"], - LOBBY_TEAM: ["Lobby.alphaChatRoomId", "Lobby.bravoChatRoomId"], + LOBBY_TEAM: ["LobbySet.alphaChatRoomId", "LobbySet.bravoChatRoomId"], } as const satisfies Record>; function noOwner( diff --git a/app/features/lobbies/LobbyMatchRepository.server.test.ts b/app/features/lobbies/LobbyMatchRepository.server.test.ts index eab5988dd..173775fad 100644 --- a/app/features/lobbies/LobbyMatchRepository.server.test.ts +++ b/app/features/lobbies/LobbyMatchRepository.server.test.ts @@ -31,9 +31,6 @@ describe("findAllTeamChatsByChatRoomIds", () => { ); await LobbyProgression.advance(lobby.id); const [set] = await LobbyMatchRepository.findAllSetsByLobbyId(lobby.id); - const { alphaChatRoomId } = (await withUserId(hostId(), () => - LobbyRepository.findById(lobby.id), - ))!; const [leaverId, ...stayingIds] = LobbySet.playersOf(set, "ALPHA"); // a set without a map reported would be called off await withUserId(leaverId, () => @@ -51,11 +48,45 @@ describe("findAllTeamChatsByChatRoomIds", () => { }); const [teamChat] = await LobbyMatchRepository.findAllTeamChatsByChatRoomIds( - [alphaChatRoomId!], + [set.alphaChatRoomId!], ); expect(teamChat.userIds).toEqual(expect.arrayContaining(stayingIds)); expect(teamChat.userIds).toHaveLength(stayingIds.length); }); + + test("every set has its own team chats, the finished set's inactive", async () => { + const lobby = await LobbyFactory.create( + { + hostUserId: hostId(), + startsAt: dateToDatabaseTimestamp(subMinutes(new Date(), 1)), + }, + { + signedUpUserIds: users.ids(8).slice(1), + checkedInUserIds: users.ids(8), + playedSetCount: 1, + }, + ); + const [previousSet, currentSet] = + await LobbyMatchRepository.findAllSetsByLobbyId(lobby.id); + + const teamChats = await LobbyMatchRepository.findAllTeamChatsByChatRoomIds([ + previousSet.alphaChatRoomId!, + currentSet.alphaChatRoomId!, + ]); + + expect(currentSet.alphaChatRoomId).not.toBe(previousSet.alphaChatRoomId); + expect( + teamChats.map(({ chatRoomId, isInactive }) => ({ + chatRoomId, + isInactive, + })), + ).toEqual( + expect.arrayContaining([ + { chatRoomId: previousSet.alphaChatRoomId, isInactive: true }, + { chatRoomId: currentSet.alphaChatRoomId, isInactive: false }, + ]), + ); + }); }); describe("confirmReadyChecks", () => { diff --git a/app/features/lobbies/LobbyMatchRepository.server.ts b/app/features/lobbies/LobbyMatchRepository.server.ts index d94247e78..a838309f6 100644 --- a/app/features/lobbies/LobbyMatchRepository.server.ts +++ b/app/features/lobbies/LobbyMatchRepository.server.ts @@ -2,6 +2,7 @@ import type { Transaction } from "kysely"; import { db } from "~/db/sql"; import type { DB, Tables } from "~/db/tables"; import { actorId } from "~/features/auth/core/user.server"; +import * as ChatRepository from "~/features/chat/ChatRepository.server"; import * as ScannerIngestRepository from "~/features/scanner-ingest/ScannerIngestRepository.server"; import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server"; import type { ModeWithStage } from "~/modules/in-game-lists/types"; @@ -10,6 +11,7 @@ import { commonUserSelect, jsonArrayFrom } from "~/utils/kysely.server"; import type { Unwrapped } from "~/utils/types"; import * as LobbySet from "./core/LobbySet"; import type { LobbySide } from "./lobbies-constants"; +import { lobbyChatRoomExpiresAt } from "./lobbies-utils"; /** A set of the lobby with its games and participants, see {@link findAllSetsByLobbyId}. */ export type LobbySetWithGames = Unwrapped; @@ -24,6 +26,8 @@ export function findAllSetsByLobbyId(lobbyId: number) { "LobbySet.forfeitingSide", "LobbySet.disputedAt", "LobbySet.confirmedAt", + "LobbySet.alphaChatRoomId", + "LobbySet.bravoChatRoomId", "LobbySet.createdAt", jsonArrayFrom( eb @@ -59,32 +63,28 @@ export function findAllSetsByLobbyId(lobbyId: number) { } /** - * Lobbies owning the given team chat rooms, with the players of the side the chat room is for in the latest - * set who are still in the room: team chats follow the current set. + * Sets owning the given team chat rooms, with the players of the side the chat room is for who are still in the + * lobby's room. A finished set's team chats are inactive. */ export async function findAllTeamChatsByChatRoomIds(chatRoomIds: number[]) { if (chatRoomIds.length === 0) return []; - const lobbies = await db - .selectFrom("Lobby") + const sets = await db + .selectFrom("LobbySet") + .innerJoin("Lobby", "Lobby.id", "LobbySet.lobbyId") .select((eb) => [ - "Lobby.id", - "Lobby.name", - "Lobby.alphaChatRoomId", - "Lobby.bravoChatRoomId", + "Lobby.id as lobbyId", + "Lobby.name as lobbyName", "Lobby.endedAt", + "LobbySet.alphaChatRoomId", + "LobbySet.bravoChatRoomId", + "LobbySet.confirmedAt", + "LobbySet.forfeitingSide", jsonArrayFrom( eb .selectFrom("LobbySetParticipant") .select(["LobbySetParticipant.userId", "LobbySetParticipant.side"]) - .where( - "LobbySetParticipant.setId", - "=", - eb - .selectFrom("LobbySet") - .select((innerEb) => innerEb.fn.max("LobbySet.id").as("id")) - .whereRef("LobbySet.lobbyId", "=", "Lobby.id"), - ) + .whereRef("LobbySetParticipant.setId", "=", "LobbySet.id") .where((innerEb) => innerEb.exists( innerEb @@ -99,30 +99,33 @@ export async function findAllTeamChatsByChatRoomIds(chatRoomIds: number[]) { .where("LobbyParticipant.status", "=", "IN_ROOM"), ), ), - ).as("latestSetParticipants"), + ).as("participants"), ]) .where((eb) => eb.or([ - eb("Lobby.alphaChatRoomId", "in", chatRoomIds), - eb("Lobby.bravoChatRoomId", "in", chatRoomIds), + eb("LobbySet.alphaChatRoomId", "in", chatRoomIds), + eb("LobbySet.bravoChatRoomId", "in", chatRoomIds), ]), ) .execute(); - return lobbies.flatMap((lobby) => + return sets.flatMap((set) => (["ALPHA", "BRAVO"] as const).flatMap((side) => { const chatRoomId = - side === "ALPHA" ? lobby.alphaChatRoomId : lobby.bravoChatRoomId; + side === "ALPHA" ? set.alphaChatRoomId : set.bravoChatRoomId; if (chatRoomId === null || !chatRoomIds.includes(chatRoomId)) return []; return [ { chatRoomId, side, - lobbyId: lobby.id, - lobbyName: lobby.name, - isInactive: lobby.endedAt !== null, - userIds: lobby.latestSetParticipants + lobbyId: set.lobbyId, + lobbyName: set.lobbyName, + isInactive: + set.endedAt !== null || + set.confirmedAt !== null || + set.forfeitingSide !== null, + userIds: set.participants .filter((participant) => participant.side === side) .map((participant) => participant.userId), }, @@ -132,7 +135,7 @@ export async function findAllTeamChatsByChatRoomIds(chatRoomIds: number[]) { } /** - * Starts a set in the room: who plays on which side (the rest sit out) and the maps. Returns the set id. + * Starts a set in the room: who plays on which side (the rest sit out), the maps and fresh team chats. Returns the set id. */ export function insertSet({ lobbyId, @@ -150,9 +153,23 @@ export function insertSet({ sittingOut: number[]; }) { return db.transaction().execute(async (trx) => { + const lobby = await trx + .selectFrom("Lobby") + .select("Lobby.startsAt") + .where("Lobby.id", "=", lobbyId) + .executeTakeFirstOrThrow(); + const [alphaChatRoomId, bravoChatRoomId] = await ChatRepository.insertRooms( + { + type: "LOBBY_TEAM", + expiresAt: lobbyChatRoomExpiresAt(lobby.startsAt), + count: 2, + }, + trx, + ); + const set = await trx .insertInto("LobbySet") - .values({ lobbyId, bestOf }) + .values({ lobbyId, bestOf, alphaChatRoomId, bravoChatRoomId }) .returning("id") .executeTakeFirstOrThrow(); diff --git a/app/features/lobbies/LobbyRepository.server.ts b/app/features/lobbies/LobbyRepository.server.ts index d34e644d6..959b731b2 100644 --- a/app/features/lobbies/LobbyRepository.server.ts +++ b/app/features/lobbies/LobbyRepository.server.ts @@ -1,4 +1,3 @@ -import { addHours } from "date-fns"; import type { ExpressionBuilder, NotNull, Transaction } from "kysely"; import { sql } from "kysely"; import { db } from "~/db/sql"; @@ -7,7 +6,7 @@ import type { AssociationVisibility } from "~/features/associations/associations import * as Visibility from "~/features/associations/core/Visibility"; import { actorIdOrNull } from "~/features/auth/core/user.server"; import * as ChatRepository from "~/features/chat/ChatRepository.server"; -import { databaseTimestampNow, databaseTimestampToDate } from "~/utils/dates"; +import { databaseTimestampNow } from "~/utils/dates"; import { shortNanoid } from "~/utils/id"; import { commonUserSelect, @@ -20,10 +19,10 @@ import { toDBBoolean } from "~/utils/sql"; import type { Unwrapped } from "~/utils/types"; import * as LobbyMatchRepository from "./LobbyMatchRepository.server"; import { - CHAT_ROOM_LIFESPAN_HOURS, CHECK_IN_LEAD_MINUTES, type LobbyParticipantStatus, } from "./lobbies-constants"; +import { lobbyChatRoomExpiresAt } from "./lobbies-utils"; const INDEX_LIST_LIMIT = 50; @@ -61,8 +60,6 @@ export async function findById( "Lobby.modes", "Lobby.roomSize", "Lobby.chatRoomId", - "Lobby.alphaChatRoomId", - "Lobby.bravoChatRoomId", "Lobby.hostUserId", "Lobby.pendingSince", "Lobby.endedAt", @@ -591,7 +588,7 @@ export async function findAllUnendedIdsCheckInOpened() { type InsertArgs = Omit< TablesInsertable["Lobby"], - "chatRoomId" | "alphaChatRoomId" | "bravoChatRoomId" | "modes" | "shareKey" + "chatRoomId" | "modes" | "shareKey" > & { modes: Tables["Lobby"]["modes"]; /** `LINK_ONLY` = seen only through the share link, by those not participating */ @@ -600,7 +597,7 @@ type InsertArgs = Omit< hostStatus?: Extract; }; -/** Inserts the lobby with its chat rooms and visibility, the host as its first participant. Returns the id. */ +/** Inserts the lobby with its chat room and visibility, the host as its first participant. Returns the id. */ export function insert({ visibility, modes, @@ -608,16 +605,8 @@ export function insert({ ...args }: InsertArgs) { return db.transaction().execute(async (trx) => { - const expiresAt = addHours( - databaseTimestampToDate(args.startsAt), - CHAT_ROOM_LIFESPAN_HOURS, - ); const chatRoom = await ChatRepository.insertRoom( - { type: "LOBBY", expiresAt }, - trx, - ); - const [alphaChatRoomId, bravoChatRoomId] = await ChatRepository.insertRooms( - { type: "LOBBY_TEAM", expiresAt, count: 2 }, + { type: "LOBBY", expiresAt: lobbyChatRoomExpiresAt(args.startsAt) }, trx, ); @@ -627,8 +616,6 @@ export function insert({ ...args, modes: JSON.stringify(modes), chatRoomId: chatRoom.id, - alphaChatRoomId, - bravoChatRoomId, shareKey: visibility === "LINK_ONLY" ? shortNanoid() : null, }) .returning(["id", "createdAt"]) @@ -825,16 +812,20 @@ export function end(id: number) { const lobby = await trx .selectFrom("Lobby") - .select([ - "Lobby.chatRoomId", - "Lobby.alphaChatRoomId", - "Lobby.bravoChatRoomId", - ]) + .select("Lobby.chatRoomId") .where("Lobby.id", "=", id) .executeTakeFirstOrThrow(); + const sets = await trx + .selectFrom("LobbySet") + .select(["LobbySet.alphaChatRoomId", "LobbySet.bravoChatRoomId"]) + .where("LobbySet.lobbyId", "=", id) + .execute(); await ChatRepository.updateRoomsInactive( - [lobby.chatRoomId, lobby.alphaChatRoomId, lobby.bravoChatRoomId], + [ + lobby.chatRoomId, + ...sets.flatMap((set) => [set.alphaChatRoomId, set.bravoChatRoomId]), + ], true, trx, ); diff --git a/app/features/lobbies/loaders/lobbies.$id.server.ts b/app/features/lobbies/loaders/lobbies.$id.server.ts index 6385066c2..a77028618 100644 --- a/app/features/lobbies/loaders/lobbies.$id.server.ts +++ b/app/features/lobbies/loaders/lobbies.$id.server.ts @@ -81,8 +81,8 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { const viewerTeamChatRoomId = isPlayingUnfinishedSet ? viewerSide === "ALPHA" - ? lobby.alphaChatRoomId - : lobby.bravoChatRoomId + ? latestSet.alphaChatRoomId + : latestSet.bravoChatRoomId : null; const userIds = R.unique([ diff --git a/app/features/lobbies/lobbies-utils.ts b/app/features/lobbies/lobbies-utils.ts index b4c502c41..d71704bd6 100644 --- a/app/features/lobbies/lobbies-utils.ts +++ b/app/features/lobbies/lobbies-utils.ts @@ -1,5 +1,8 @@ +import { addHours } from "date-fns"; import { CHANNEL_PREFIX } from "~/features/events/events-types"; +import { databaseTimestampToDate } from "~/utils/dates"; import { lobbyPage, SENDOU_INK_BASE_URL } from "~/utils/urls"; +import { CHAT_ROOM_LIFESPAN_HOURS } from "./lobbies-constants"; import { lobbySearchParams } from "./lobbies-search-params"; /** Topic the lobby's page revalidates on: every change to its participants and sets. */ @@ -15,3 +18,7 @@ export const lobbyShareLink = ({ shareKey: string; }) => `${SENDOU_INK_BASE_URL}${lobbySearchParams.href(lobbyPage(lobbyId), { key: shareKey })}`; + +/** When the lobby's chat rooms, its own and its sets' team chats, expire. */ +export const lobbyChatRoomExpiresAt = (startsAt: number) => + addHours(databaseTimestampToDate(startsAt), CHAT_ROOM_LIFESPAN_HOURS); diff --git a/app/features/lobbies/routes/lobbies.$id.tsx b/app/features/lobbies/routes/lobbies.$id.tsx index b2f9de808..d281db3d3 100644 --- a/app/features/lobbies/routes/lobbies.$id.tsx +++ b/app/features/lobbies/routes/lobbies.$id.tsx @@ -49,8 +49,8 @@ export default function LobbyPage() { useVoiceFollow({ roomIds: [ data.lobby.chatRoomId, - data.lobby.alphaChatRoomId, - data.lobby.bravoChatRoomId, + data.sets.at(-1)?.alphaChatRoomId ?? null, + data.sets.at(-1)?.bravoChatRoomId ?? null, ].filter((id) => id !== null), targetRoomId: data.voiceRoomId, }); diff --git a/app/features/map-list-generator/core/MapList.test.ts b/app/features/map-list-generator/core/MapList.test.ts index bf59da889..a16ab9b43 100644 --- a/app/features/map-list-generator/core/MapList.test.ts +++ b/app/features/map-list-generator/core/MapList.test.ts @@ -768,8 +768,8 @@ describe("MapList.generate() with history", () => { return gen.next({ amount }).value; } - function szHistory(stageIds: StageId[]): ModeWithStage[] { - return stageIds.map((stageId) => ({ mode: "SZ", stageId })); + function szHistory(historyStageIds: StageId[]): ModeWithStage[] { + return historyStageIds.map((stageId) => ({ mode: "SZ", stageId })); } test("does not start with the last played mode", () => { diff --git a/app/features/voice/voice-hooks.ts b/app/features/voice/voice-hooks.ts index 319329f93..3dffea703 100644 --- a/app/features/voice/voice-hooks.ts +++ b/app/features/voice/voice-hooks.ts @@ -46,8 +46,8 @@ export function useVoiceRoom(room: { /** * Keeps the user's call following `targetRoomId` among related rooms: when the target changes (say the user's - * team for a lobby's next set) a call in any of `roomIds` moves to it. Joining the target in the first place - * stays the user's choice, and so does switching between the rooms while the target holds. + * team for a lobby's next set) a call in any of `roomIds`, or in the previous target, moves to it. Joining the + * target in the first place stays the user's choice, and so does switching between the rooms while the target holds. */ export function useVoiceFollow({ roomIds, @@ -66,7 +66,11 @@ export function useVoiceFollow({ if (targetRoomId === null) return; void voiceClient.follow({ - roomIds: roomIdsKey.split(",").map(Number), + // the previous target may be gone from `roomIds`, like a lobby's previous set's team chat + roomIds: [ + ...roomIdsKey.split(",").map(Number), + ...(followedRoomId === null ? [] : [followedRoomId]), + ], targetRoomId, }); }, [targetRoomId, followedRoomId, roomIdsKey]); diff --git a/e2e/lobbies.spec.ts b/e2e/lobbies.spec.ts index 559a0aeab..f04131f82 100644 --- a/e2e/lobbies.spec.ts +++ b/e2e/lobbies.spec.ts @@ -1338,7 +1338,8 @@ test.describe("Lobbies", () => { /** * A lobby has a chat for everyone in it and one per team, a team's chat reaching only its - * players. The lobby page opens a player's two chats side by side. + * players. Every set starts with fresh team chats while the lobby's chat carries on. The + * lobby page opens a player's two chats side by side. */ test.describe("Chat", () => { test("a team's chat reaches only its players", async ({ @@ -1397,6 +1398,85 @@ test.describe("Lobbies", () => { await expect(chat.locators.roomRows).toContainText("Lobby ยท Team"); }); }); + + test("every set starts with fresh team chats", async ({ + page, + factories, + }) => { + const { lobby, players, alphaPlayers, bravoPlayers } = await startedLobby( + page, + factories, + ); + const sender = alphaPlayers[0]; + const lobbyPage = new LobbyPage(page); + const chat = new ChatSidebar(page); + + await test.step("An Alpha player writes to their team and to the lobby in the first set", async () => { + await impersonate(page, sender.id); + await lobbyPage.goto(lobby.id); + + await chat.selectRoomTab("Team"); + await chat.chat().send("first set plan"); + await expect(chat.chat().message("first set plan")).toBeVisible(); + + await chat.selectRoomTab("Lobby"); + await chat.chat().send("hello lobby"); + await expect(chat.chat().message("hello lobby")).toBeVisible(); + }); + + await playSet(page, { + lobbyId: lobby.id, + alphaPlayer: sender, + bravoPlayer: bravoPlayers[0], + }); + + await expect(lobbyPage.locators.reportScoreButton).toBeVisible(); + const nextAlphaIds = await rosterIds(lobbyPage, "Alpha", players); + const senderTeamIds = nextAlphaIds.includes(sender.id) + ? nextAlphaIds + : idsOf(players).filter((id) => !nextAlphaIds.includes(id)); + const newTeammate = players.find( + (player) => + player.id !== sender.id && senderTeamIds.includes(player.id), + )!; + // the teams were reshuffled, so someone of the first set's team now plays against the sender + const formerTeammate = alphaPlayers.find( + (player) => !senderTeamIds.includes(player.id), + )!; + + await test.step("The sender's team chat of the next set starts empty", async () => { + await impersonate(page, sender.id); + await lobbyPage.goto(lobby.id); + + await chat.selectRoomTab("Team"); + await chat.chat().send("second set plan"); + await expect(chat.chat().message("second set plan")).toBeVisible(); + await expect(chat.chat().message("first set plan")).toHaveCount(0); + + await chat.selectRoomTab("Lobby"); + await expect(chat.chat().message("hello lobby")).toBeVisible(); + }); + + await test.step("Their new teammate reads only the next set's message", async () => { + await impersonate(page, newTeammate.id); + await lobbyPage.goto(lobby.id); + + await chat.selectRoomTab("Team"); + await expect(chat.chat().message("second set plan")).toBeVisible(); + await expect(chat.chat().message("first set plan")).toHaveCount(0); + }); + + await test.step("A former teammate now on the other team reads neither", async () => { + await impersonate(page, formerTeammate.id); + await lobbyPage.goto(lobby.id); + + await chat.selectRoomTab("Team"); + await chat.chat().send("other team plan"); + await expect(chat.chat().message("other team plan")).toBeVisible(); + await expect(chat.chat().message("second set plan")).toHaveCount(0); + await expect(chat.chat().message("first set plan")).toHaveCount(0); + }); + }); }); /** diff --git a/migrations/20261027183901-lobby-set-team-chat-rooms.ts b/migrations/20261027183901-lobby-set-team-chat-rooms.ts new file mode 100644 index 000000000..c699cdc3d --- /dev/null +++ b/migrations/20261027183901-lobby-set-team-chat-rooms.ts @@ -0,0 +1,64 @@ +import { type Kysely, sql } from "kysely"; + +/** Moves the lobby team chat rooms from `Lobby` to `LobbySet`, so every set starts with fresh team chats. */ +export async function up(db: Kysely): Promise { + await db.transaction().execute(async (trx) => { + await trx.schema + .alterTable("LobbySet") + .addColumn("alphaChatRoomId", "integer", (col) => + col.references("ChatRoom.id").onDelete("set null"), + ) + .execute(); + await trx.schema + .alterTable("LobbySet") + .addColumn("bravoChatRoomId", "integer", (col) => + col.references("ChatRoom.id").onDelete("set null"), + ) + .execute(); + + await sql` + update "LobbySet" + set + "alphaChatRoomId" = (select "alphaChatRoomId" from "Lobby" where "Lobby"."id" = "LobbySet"."lobbyId"), + "bravoChatRoomId" = (select "bravoChatRoomId" from "Lobby" where "Lobby"."id" = "LobbySet"."lobbyId") + where "LobbySet"."id" = ( + select max("Latest"."id") from "LobbySet" as "Latest" where "Latest"."lobbyId" = "LobbySet"."lobbyId" + ) + `.execute(trx); + + for (const column of ["alphaChatRoomId", "bravoChatRoomId"]) { + await sql + .raw(`drop trigger "lobby_${snakeCase(column)}_deletes_chat_room"`) + .execute(trx); + await trx.schema.dropIndex(`lobby_${snakeCase(column)}`).execute(); + await trx.schema.alterTable("Lobby").dropColumn(column).execute(); + + await trx.schema + .createIndex(`lobby_set_${snakeCase(column)}`) + .on("LobbySet") + .column(column) + .unique() + .where(sql.ref(column), "is not", null) + .execute(); + await sql + .raw( + `create trigger "lobby_set_${snakeCase(column)}_deletes_chat_room" after delete on "LobbySet" + when old."${column}" is not null + begin delete from "ChatRoom" where "id" = old."${column}"; end`, + ) + .execute(trx); + } + + // team rooms of lobbies that never got to a set + await sql` + delete from "ChatRoom" + where "type" = 'LOBBY_TEAM' + and not exists (select 1 from "LobbySet" where "alphaChatRoomId" = "ChatRoom"."id") + and not exists (select 1 from "LobbySet" where "bravoChatRoomId" = "ChatRoom"."id") + `.execute(trx); + }); +} + +function snakeCase(value: string) { + return value.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase(); +}