diff --git a/app/db/seed/factories/TournamentTeamFactory.ts b/app/db/seed/factories/TournamentTeamFactory.ts index 480ef0ff7..39dd10ae0 100644 --- a/app/db/seed/factories/TournamentTeamFactory.ts +++ b/app/db/seed/factories/TournamentTeamFactory.ts @@ -1,3 +1,4 @@ +import { addDays } from "date-fns"; import type { MapPool } from "~/features/map-list-generator/core/map-pool"; import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server"; @@ -95,7 +96,10 @@ export const { create } = defineFactory({ } if (isLooking) { - await TournamentLFGRepository.startLooking(team.id); + await TournamentLFGRepository.startLooking({ + teamId: team.id, + chatRoomExpiresAt: addDays(new Date(), 7), + }); } }, }); diff --git a/app/db/tables.ts b/app/db/tables.ts index e4972d985..06d895d95 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -331,7 +331,6 @@ export interface ChatMessageReadIndicator { } export interface Group { - chatCode: string | null; chatRoomId: number | null; createdAt: Generated; id: GeneratedAlways; @@ -365,7 +364,6 @@ export interface GroupLike { export interface GroupMatch { alphaGroupId: number; bravoGroupId: number; - chatCode: string | null; chatRoomId: number | null; confirmedAt: number | null; confirmedByUserId: number | null; @@ -698,7 +696,6 @@ export interface TournamentGroup { } export interface TournamentMatch { - chatCode: string | null; chatRoomId: number | null; groupId: number; id: GeneratedAlways; @@ -817,7 +814,6 @@ export interface TournamentTeam { isLooking: Generated; isPlaceholder: Generated; lfgNote: string | null; - chatCode: Generated; chatRoomId: number | null; /** A/B division assignment for bipartite round robin brackets. `0` = A, `1` = B, `null` = unassigned. */ abDivision: number | null; @@ -1222,8 +1218,6 @@ export interface ScrimPost { visibility: JSONColumnTypeNullable; /** Any additional info */ text: string | null; - /** The key to access the scrim chat, used after scrim is scheduled with another team */ - chatCode: string; // xxx: remember to drop all chat codes chatRoomId: number | null; /** Refers to the team looking for the team (can also be a pick-up) */ teamId: number | null; diff --git a/app/features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts b/app/features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts index 8e2f95692..ce5149eee 100644 --- a/app/features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts +++ b/app/features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts @@ -3,6 +3,7 @@ import * as v from "valibot"; import { requireUser } from "~/features/auth/core/user.server"; import { userIsBanned } from "~/features/ban/core/banned.server"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; +import * as EventBus from "~/features/events/core/EventBus.server"; import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; import { notify } from "~/features/notifications/core/notify.server"; import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; @@ -99,7 +100,9 @@ export const action = async (args: ActionFunctionArgs) => { }); if (previousTeamPickupChat) { - ChatSystemMessage.removeRoom(previousTeamPickupChat.chatCode); + ChatSystemMessage.removeRoom( + EventBus.chatRoomChannel(previousTeamPickupChat.chatRoomId), + ); } ShowcaseTournaments.addToCached({ diff --git a/app/features/calendar/CalendarRepository.server.ts b/app/features/calendar/CalendarRepository.server.ts index 4e7177b9d..6bbea3542 100644 --- a/app/features/calendar/CalendarRepository.server.ts +++ b/app/features/calendar/CalendarRepository.server.ts @@ -11,6 +11,7 @@ import { db } from "~/db/sql"; import type { DB, Tables } from "~/db/tables"; import type { TournamentSettings } from "~/db/tables-json"; import { EXCLUDED_TAGS } from "~/features/calendar/calendar-constants"; +import * as ChatRepository from "~/features/chat/ChatRepository.server"; import * as Progression from "~/features/tournament-bracket/core/Progression"; import { getTentativeTier } from "~/features/tournament-organization/core/tentativeTiers.server"; import { @@ -863,6 +864,28 @@ export function deleteById({ return db.transaction().execute(async (trx) => { await trx.deleteFrom("CalendarEvent").where("id", "=", eventId).execute(); if (tournamentId) { + const teamChatRooms = await trx + .selectFrom("TournamentTeam") + .select("TournamentTeam.chatRoomId") + .where("TournamentTeam.tournamentId", "=", tournamentId) + .where("TournamentTeam.chatRoomId", "is not", null) + .execute(); + const matchChatRooms = await trx + .selectFrom("TournamentMatch") + .innerJoin( + "TournamentStage", + "TournamentStage.id", + "TournamentMatch.stageId", + ) + .select("TournamentMatch.chatRoomId") + .where("TournamentStage.tournamentId", "=", tournamentId) + .where("TournamentMatch.chatRoomId", "is not", null) + .execute(); + await ChatRepository.deleteRoomsByIds( + [...teamChatRooms, ...matchChatRooms].map((room) => room.chatRoomId), + trx, + ); + await trx .deleteFrom("Tournament") .where("id", "=", tournamentId) diff --git a/app/features/chat/ChatRepository.server.ts b/app/features/chat/ChatRepository.server.ts index 3f1eb8b88..299b56466 100644 --- a/app/features/chat/ChatRepository.server.ts +++ b/app/features/chat/ChatRepository.server.ts @@ -64,6 +64,36 @@ export function insertRoom( .executeTakeFirstOrThrow(); } +/** Extends a room's lifetime, e.g. when a successor group carries its chat over. */ +export async function updateRoomExpiresAt( + args: { roomId: number; expiresAt: Date }, + trx?: Transaction, +) { + const executor = trx ?? db; + + await executor + .updateTable("ChatRoom") + .set({ expiresAt: dateToDatabaseTimestamp(args.expiresAt) }) + .where("ChatRoom.id", "=", args.roomId) + .execute(); +} + +/** Deletes rooms and their messages. Called in the owning entity's delete transaction. */ +export async function deleteRoomsByIds( + roomIds: Array, + trx?: Transaction, +) { + const idsToDelete = roomIds.filter((id) => id !== null); + if (idsToDelete.length === 0) return; + + const executor = trx ?? db; + + await executor + .deleteFrom("ChatRoom") + .where("ChatRoom.id", "in", idsToDelete) + .execute(); +} + type InsertMessageArgs = Pick< TablesInsertable["ChatMessage"], "roomId" | "publicId" diff --git a/app/features/events/core/EventBus.server.ts b/app/features/events/core/EventBus.server.ts index ebf8d61f4..0f16c3662 100644 --- a/app/features/events/core/EventBus.server.ts +++ b/app/features/events/core/EventBus.server.ts @@ -7,6 +7,11 @@ export function userChannel(userId: number): string { return `user__${userId}`; } +/** Channel delivering a chat room's events to its viewers. */ +export function chatRoomChannel(roomId: number): string { + return `chat-room__${roomId}`; +} + interface Subscriber { queue: ServerEvent[]; wake: (() => void) | null; diff --git a/app/features/scrims/ScrimPostRepository.server.test.ts b/app/features/scrims/ScrimPostRepository.server.test.ts index bea52b510..89bddd3dd 100644 --- a/app/features/scrims/ScrimPostRepository.server.test.ts +++ b/app/features/scrims/ScrimPostRepository.server.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, test } from "vitest"; import * as ScrimPostFactory from "~/db/seed/factories/ScrimPostFactory"; import * as TeamFactory from "~/db/seed/factories/TeamFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; +import { db } from "~/db/sql"; import { dateToDatabaseTimestamp } from "~/utils/dates"; import { DuplicateEntryError } from "~/utils/errors"; import * as ScrimPostRepository from "./ScrimPostRepository.server"; @@ -324,3 +325,88 @@ describe("insertRequest", () => { expect(otherPost!.requests).toHaveLength(1); }); }); + +describe("acceptRequest", () => { + beforeEach(async () => { + await users.create(3); + }); + + const setupPostWithRequest = async ({ + requestStartsAt, + }: { + requestStartsAt?: Date; + } = {}) => { + const { id: postId } = await ScrimPostFactory.create({ + startsAt: dbTs(BOOKED_AT), + users: [{ userId: users.id(1), isOwner: 1 }], + }); + const team = await TeamFactory.create({ memberUserIds: [users.id(2)] }); + const requestId = await ScrimPostRepository.insertRequest({ + scrimPostId: postId, + teamId: team.id, + message: null, + startsAt: requestStartsAt ? dbTs(requestStartsAt) : null, + users: [{ userId: users.id(2), isOwner: 1 }], + }); + + return { postId, requestId }; + }; + + const roomOfPost = async (postId: number) => { + const post = await ScrimPostRepository.findById(postId); + return db + .selectFrom("ChatRoom") + .selectAll() + .where("id", "=", post!.chatRoomId!) + .executeTakeFirstOrThrow(); + }; + + test("creates a SCRIM chat room expiring a day after the scrim's start time", async () => { + const { postId, requestId } = await setupPostWithRequest(); + + await ScrimPostRepository.acceptRequest(requestId); + + const room = await roomOfPost(postId); + expect(room.type).toBe("SCRIM"); + expect(room.expiresAt).toBe(dbTs(BOOKED_AT) + 24 * 60 * 60); + }); + + test("room expiry follows the accepted request's start time when it has one", async () => { + const requestStartsAt = add(BOOKED_AT, { hours: 5 }); + const { postId, requestId } = await setupPostWithRequest({ + requestStartsAt, + }); + + await ScrimPostRepository.acceptRequest(requestId); + + const room = await roomOfPost(postId); + expect(room.expiresAt).toBe(dbTs(requestStartsAt) + 24 * 60 * 60); + }); +}); + +describe("deleteById", () => { + beforeEach(async () => { + await users.create(3); + }); + + test("deletes the scrim's chat room with the post", async () => { + const { id: postId } = await ScrimPostFactory.create({ + startsAt: dbTs(BOOKED_AT), + users: [{ userId: users.id(1), isOwner: 1 }], + }); + const team = await TeamFactory.create({ memberUserIds: [users.id(2)] }); + const requestId = await ScrimPostRepository.insertRequest({ + scrimPostId: postId, + teamId: team.id, + message: null, + startsAt: null, + users: [{ userId: users.id(2), isOwner: 1 }], + }); + await ScrimPostRepository.acceptRequest(requestId); + + await ScrimPostRepository.deleteById(postId); + + const rooms = await db.selectFrom("ChatRoom").selectAll().execute(); + expect(rooms).toHaveLength(0); + }); +}); diff --git a/app/features/scrims/ScrimPostRepository.server.ts b/app/features/scrims/ScrimPostRepository.server.ts index 38ef46241..8917124a2 100644 --- a/app/features/scrims/ScrimPostRepository.server.ts +++ b/app/features/scrims/ScrimPostRepository.server.ts @@ -1,13 +1,17 @@ -import { sub } from "date-fns"; +import { addHours, sub } from "date-fns"; import type { Insertable } from "kysely"; import type { Tables, TablesInsertable } from "~/db/tables"; import { actorId, actorIdOrNull } from "~/features/auth/core/user.server"; -import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates"; +import * as ChatRepository from "~/features/chat/ChatRepository.server"; +import { + databaseTimestampNow, + databaseTimestampToDate, + dateToDatabaseTimestamp, +} from "~/utils/dates"; import { ConcurrentModificationError, DuplicateEntryError, } from "~/utils/errors"; -import { shortNanoid } from "~/utils/id"; import { type CommonUser, commonUserSelect, @@ -24,6 +28,8 @@ import * as Scrim from "./core/Scrim"; import type { ScrimPost, ScrimPostUser } from "./scrims-types"; import { getPostRequestCensor, parseLutiDiv } from "./scrims-utils"; +const CHAT_ROOM_LIFESPAN_HOURS = 24; + type InsertArgs = Pick< TablesInsertable["ScrimPost"], | "startsAt" @@ -60,7 +66,6 @@ export function insert(args: InsertArgs) { maps: args.maps, mapsTournamentId: args.mapsTournamentId, visibility: args.visibility ? JSON.stringify(args.visibility) : null, - chatCode: shortNanoid(), managedByAnyone: args.managedByAnyone ? 1 : 0, isScheduledForFuture: args.isScheduledForFuture ? 1 : 0, }) @@ -137,7 +142,16 @@ export function insertRequest(args: InsertRequestArgs) { } export function deleteById(scrimPostId: number) { - return db.deleteFrom("ScrimPost").where("id", "=", scrimPostId).execute(); + return db.transaction().execute(async (trx) => { + const post = await trx + .selectFrom("ScrimPost") + .select("ScrimPost.chatRoomId") + .where("id", "=", scrimPostId) + .executeTakeFirst(); + await ChatRepository.deleteRoomsByIds([post?.chatRoomId ?? null], trx); + + await trx.deleteFrom("ScrimPost").where("id", "=", scrimPostId).execute(); + }); } const baseFindQuery = db @@ -240,7 +254,7 @@ function findMany() { } const mapDBRowToScrimPost = ( - row: Unwrapped & { chatCode?: string }, + row: Unwrapped & { chatRoomId?: number | null }, ): ScrimPost => { const someRequestIsAccepted = row.requests.some( (request) => request.isAccepted, @@ -300,7 +314,7 @@ const mapDBRowToScrimPost = ( avatarUrl: row.mapsTournament.avatarUrl, } : null, - chatCode: row.chatCode ?? null, + chatRoomId: row.chatRoomId ?? null, team: row.team.name ? { name: row.team.name, @@ -359,7 +373,7 @@ const mapDBRowToScrimPost = ( export async function findById(scrimPostId: number): Promise { const row = await baseFindQuery - .select(["ScrimPost.chatCode"]) + .select(["ScrimPost.chatRoomId"]) .where("ScrimPost.id", "=", scrimPostId) .executeTakeFirst(); @@ -411,6 +425,36 @@ export function acceptRequest(scrimPostRequestId: number) { "Another request for this scrim post was already accepted", ); } + + // the scrim is now scheduled, so its chat becomes available + const request = await trx + .selectFrom("ScrimPostRequest") + .select("ScrimPostRequest.startsAt") + .where("id", "=", scrimPostRequestId) + .executeTakeFirstOrThrow(); + const post = await trx + .selectFrom("ScrimPost") + .select(["ScrimPost.chatRoomId", "ScrimPost.startsAt"]) + .where("ScrimPost.id", "=", target.scrimPostId) + .executeTakeFirstOrThrow(); + + if (post.chatRoomId === null) { + const scrimStartsAt = databaseTimestampToDate( + request.startsAt ?? post.startsAt, + ); + const chatRoom = await ChatRepository.insertRoom( + { + type: "SCRIM", + expiresAt: addHours(scrimStartsAt, CHAT_ROOM_LIFESPAN_HOURS), + }, + trx, + ); + await trx + .updateTable("ScrimPost") + .set({ chatRoomId: chatRoom.id }) + .where("ScrimPost.id", "=", target.scrimPostId) + .execute(); + } }); } diff --git a/app/features/scrims/actions/scrims.$id.server.ts b/app/features/scrims/actions/scrims.$id.server.ts index 42ecbfe05..233971d7b 100644 --- a/app/features/scrims/actions/scrims.$id.server.ts +++ b/app/features/scrims/actions/scrims.$id.server.ts @@ -1,5 +1,6 @@ import type { ActionFunctionArgs } from "react-router"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; +import * as EventBus from "~/features/events/core/EventBus.server"; import { notify } from "~/features/notifications/core/notify.server"; import { resolveNotifications } from "~/features/notifications/core/resolve.server"; import { parseFormData } from "~/form/parse.server"; @@ -229,9 +230,9 @@ async function loadMapByMapContext({ function broadcastRevalidate( post: NonNullable>>, ) { - if (!post.chatCode) return; + if (!post.chatRoomId) return; ChatSystemMessage.send({ - room: post.chatCode, + room: EventBus.chatRoomChannel(post.chatRoomId), revalidateOnly: true, }); } @@ -245,9 +246,9 @@ function broadcastMapChange({ type: "MAP_REPLAYED" | "MAP_PICKED"; user: ReturnType; }) { - if (!post.chatCode) return; + if (!post.chatRoomId) return; ChatSystemMessage.send({ - room: post.chatCode, + room: EventBus.chatRoomChannel(post.chatRoomId), type, context: { name: user.username }, }); diff --git a/app/features/scrims/actions/scrims.server.ts b/app/features/scrims/actions/scrims.server.ts index 24c0d06fe..eedce76f5 100644 --- a/app/features/scrims/actions/scrims.server.ts +++ b/app/features/scrims/actions/scrims.server.ts @@ -6,6 +6,7 @@ import * as Association from "~/features/associations/core/Association"; import { requireUser } from "~/features/auth/core/user.server"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; import { datePlaceholder } from "~/features/chat/chat-utils"; +import * as EventBus from "~/features/events/core/EventBus.server"; import { notify } from "~/features/notifications/core/notify.server"; import { resolveNotifications } from "~/features/notifications/core/resolve.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; @@ -180,9 +181,9 @@ export const action = async ({ request }: ActionFunctionArgs) => { }); const fullPost = await ScrimPostRepository.findById(post.id); - if (fullPost?.chatCode) { + if (fullPost?.chatRoomId) { ChatSystemMessage.setMetadata({ - chatCode: fullPost.chatCode, + chatCode: EventBus.chatRoomChannel(fullPost.chatRoomId), header: datePlaceholder( databaseTimestampToDate(request.startsAt ?? post.startsAt), ), diff --git a/app/features/scrims/core/Scrim.test.ts b/app/features/scrims/core/Scrim.test.ts index 4f81d4622..f3631959b 100644 --- a/app/features/scrims/core/Scrim.test.ts +++ b/app/features/scrims/core/Scrim.test.ts @@ -122,7 +122,7 @@ describe("applyFilters", () => { canceled: null, createdAt: databaseTimestampNow(), visibility: null, - chatCode: null, + chatRoomId: null, text: "", maps: null, isScheduledForFuture: false, diff --git a/app/features/scrims/loaders/scrims.$id.server.ts b/app/features/scrims/loaders/scrims.$id.server.ts index 89773e90b..ed0045692 100644 --- a/app/features/scrims/loaders/scrims.$id.server.ts +++ b/app/features/scrims/loaders/scrims.$id.server.ts @@ -1,5 +1,6 @@ import type { LoaderFunctionArgs } from "react-router"; import { chatAccessible } from "~/features/chat/chat-utils"; +import * as EventBus from "~/features/events/core/EventBus.server"; import { resolveNotifications } from "~/features/notifications/core/resolve.server"; import * as UserCardRepository from "~/features/user-card/UserCardRepository.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; @@ -56,12 +57,13 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { post, chatCode: (user.roles.includes("STAFF") || participantIds.includes(user.id)) && + post.chatRoomId !== null && chatAccessible({ isStaff: user.roles.includes("STAFF"), expiresAfterDays: 1, comparedTo: databaseTimestampToDate(Scrim.getStartTime(post)), }) - ? post.chatCode + ? EventBus.chatRoomChannel(post.chatRoomId) : undefined, anyUserPrefersNoScreen, mapByMap, diff --git a/app/features/scrims/scrims-types.ts b/app/features/scrims/scrims-types.ts index 012f411ad..e09cbcee9 100644 --- a/app/features/scrims/scrims-types.ts +++ b/app/features/scrims/scrims-types.ts @@ -27,7 +27,7 @@ export interface ScrimPost { } | null; team: ScrimPostTeam | null; users: Array; - chatCode: string | null; + chatRoomId: number | null; requests: Array; /** Is the post visible to the user because of their association membership? */ isPrivate?: boolean; diff --git a/app/features/sendouq-match/SQMatchRepository.server.test.ts b/app/features/sendouq-match/SQMatchRepository.server.test.ts index da25b9b9a..3a3458768 100644 --- a/app/features/sendouq-match/SQMatchRepository.server.test.ts +++ b/app/features/sendouq-match/SQMatchRepository.server.test.ts @@ -95,6 +95,19 @@ const playOutMatch = async (setup: Awaited>) => { }; describe("insert", () => { + test("creates an SQ_MATCH chat room owned by the match", async () => { + const { match } = await setupMatch(); + + expect(match.chatRoomId).toEqual(expect.any(Number)); + + const room = await db + .selectFrom("ChatRoom") + .selectAll() + .where("id", "=", match.chatRoomId!) + .executeTakeFirstOrThrow(); + expect(room.type).toBe("SQ_MATCH"); + }); + test("deletes the matched groups' pending likes and suggestions", async () => { const users = await UserFactory.createMany(FULL_GROUP_SIZE * 2 + 1); const alphaMembers = users.slice(0, FULL_GROUP_SIZE); diff --git a/app/features/sendouq-match/SQMatchRepository.server.ts b/app/features/sendouq-match/SQMatchRepository.server.ts index 855419f42..9465ed2be 100644 --- a/app/features/sendouq-match/SQMatchRepository.server.ts +++ b/app/features/sendouq-match/SQMatchRepository.server.ts @@ -1,4 +1,4 @@ -import { startOfYear } from "date-fns"; +import { addHours, startOfYear } from "date-fns"; import type { Expression, ExpressionBuilder, @@ -10,6 +10,7 @@ import * as R from "remeda"; 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 { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/leaderboards-constants"; import * as Seasons from "~/features/mmr/core/Seasons"; import { @@ -26,7 +27,6 @@ import { databaseTimestampToDate, dateToDatabaseTimestamp, } from "~/utils/dates"; -import { shortNanoid } from "~/utils/id"; import invariant from "~/utils/invariant"; import { commonUserSelect, @@ -55,6 +55,8 @@ import * as MatchSkillRepository from "./MatchSkillRepository.server"; import * as PlayerStatRepository from "./PlayerStatRepository.server"; import * as ReportedWeaponRepository from "./ReportedWeaponRepository.server"; +const CHAT_ROOM_LIFESPAN_HOURS = 24; + /** Whether a GroupMatch with the given id exists. */ export async function exists(id: number) { const row = await db @@ -74,7 +76,7 @@ export async function findById(id: number) { "GroupMatch.createdAt", "GroupMatch.confirmedAt", "GroupMatch.confirmedByUserId", - "GroupMatch.chatCode", + "GroupMatch.chatRoomId", "GroupMatch.cancelRequestedByUserId", "GroupMatch.cancelAcceptedByUserId", "GroupMatch.noScreen", @@ -209,7 +211,7 @@ function groupWithTeamAndMembers( .selectFrom("Group") .select(({ eb }) => [ "Group.id", - "Group.chatCode", + "Group.chatRoomId", "Group.matchmade", "Group.tierName", "Group.tierIsPlus", @@ -803,12 +805,20 @@ export function insert({ .where("User.noScreen", "=", 1) .executeTakeFirst(); + const chatRoom = await ChatRepository.insertRoom( + { + type: "SQ_MATCH", + expiresAt: addHours(new Date(), CHAT_ROOM_LIFESPAN_HOURS), + }, + trx, + ); + const match = await trx .insertInto("GroupMatch") .values({ alphaGroupId, bravoGroupId, - chatCode: shortNanoid(), + chatRoomId: chatRoom.id, noScreen: memberPreferringNoScreen ? 1 : 0, }) .returningAll() @@ -1573,7 +1583,7 @@ function findLockState(matchId: number, trx: Transaction) { export function findUnfinishedMatchesCreatedBefore(cutoff: Date) { return db .selectFrom("GroupMatch") - .select(["GroupMatch.id", "GroupMatch.chatCode"]) + .select(["GroupMatch.id", "GroupMatch.chatRoomId"]) .where("GroupMatch.confirmedAt", "is", null) .where("GroupMatch.createdAt", "<", dateToDatabaseTimestamp(cutoff)) .where(({ not, exists, selectFrom }) => diff --git a/app/features/sendouq-match/actions/q.match.$id.server.ts b/app/features/sendouq-match/actions/q.match.$id.server.ts index dbee3f5f1..cc08ec095 100644 --- a/app/features/sendouq-match/actions/q.match.$id.server.ts +++ b/app/features/sendouq-match/actions/q.match.$id.server.ts @@ -3,6 +3,7 @@ import * as R from "remeda"; import { db } from "~/db/sql"; import { requireUser } from "~/features/auth/core/user.server"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; +import * as EventBus from "~/features/events/core/EventBus.server"; import * as Seasons from "~/features/mmr/core/Seasons"; import { refreshUserSkills } from "~/features/mmr/tiered.server"; import { @@ -97,16 +98,16 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { await refreshSendouQInstance(); - if (match.chatCode) { + if (match.chatRoomId) { if (result.status === "MATCH_FINALIZED") { ChatSystemMessage.send({ - room: match.chatCode, + room: EventBus.chatRoomChannel(match.chatRoomId), type: "SCORE_CONFIRMED", context: { name: user.username }, }); } else { ChatSystemMessage.send({ - room: match.chatCode, + room: EventBus.chatRoomChannel(match.chatRoomId), revalidateOnly: true, }); } @@ -152,17 +153,17 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { await refreshSendouQInstance(); - // the successor group reuses the chat code, extend the room's expiry - if (previousGroup.chatCode) { + // the successor group carries the chat room over, extend its expiry + if (previousGroup.chatRoomId) { setGroupChatMetadata({ - chatCode: previousGroup.chatCode, + chatRoomId: previousGroup.chatRoomId, members: previousGroup.members, }); } - if (match.chatCode) { + if (match.chatRoomId) { ChatSystemMessage.send({ - room: match.chatCode, + room: EventBus.chatRoomChannel(match.chatRoomId), revalidateOnly: true, }); } @@ -240,11 +241,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { await refreshSendouQInstance(); - // the successor group reuses the chat code; sync the room to the - // continuing members and extend its expiry - if (viewerGroup.chatCode && survivors.length > 0) { + // the successor group carries the chat room over; sync the room to + // the continuing members and extend its expiry + if (viewerGroup.chatRoomId && survivors.length > 0) { setGroupChatMetadata({ - chatCode: viewerGroup.chatCode, + chatRoomId: viewerGroup.chatRoomId, members: survivors, }); } @@ -257,9 +258,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { }); } - if (match.chatCode) { + if (match.chatRoomId) { ChatSystemMessage.send({ - room: match.chatCode, + room: EventBus.chatRoomChannel(match.chatRoomId), revalidateOnly: true, }); } @@ -299,9 +300,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { await refreshSendouQInstance(); - if (match.chatCode) { + if (match.chatRoomId) { ChatSystemMessage.send({ - room: match.chatCode, + room: EventBus.chatRoomChannel(match.chatRoomId), revalidateOnly: true, }); } @@ -323,9 +324,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { await refreshSendouQInstance(); - if (match.chatCode) { + if (match.chatRoomId) { ChatSystemMessage.send({ - room: match.chatCode, + room: EventBus.chatRoomChannel(match.chatRoomId), revalidateOnly: true, }); } @@ -347,9 +348,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { return null; } - if (match.chatCode) { + if (match.chatRoomId) { ChatSystemMessage.send({ - room: match.chatCode, + room: EventBus.chatRoomChannel(match.chatRoomId), type: "CANCEL_REPORTED", context: { name: user.username }, }); @@ -378,9 +379,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { await notifyStaffOfCanceledMatch(match); - if (match.chatCode) { + if (match.chatRoomId) { ChatSystemMessage.send({ - room: match.chatCode, + room: EventBus.chatRoomChannel(match.chatRoomId), type: "CANCEL_CONFIRMED", context: { name: user.username }, }); @@ -408,9 +409,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { await refreshSendouQInstance(); - if (match.chatCode) { + if (match.chatRoomId) { ChatSystemMessage.send({ - room: match.chatCode, + room: EventBus.chatRoomChannel(match.chatRoomId), revalidateOnly: true, }); } @@ -433,9 +434,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { return errorToast("Cannot refuse own cancel request"); } - if (match.chatCode) { + if (match.chatRoomId) { ChatSystemMessage.send({ - room: match.chatCode, + room: EventBus.chatRoomChannel(match.chatRoomId), type: "CANCEL_REFUSED", context: { name: user.username }, }); diff --git a/app/features/sendouq-match/loaders/q.match.$id.server.ts b/app/features/sendouq-match/loaders/q.match.$id.server.ts index b57beb720..e94bd382f 100644 --- a/app/features/sendouq-match/loaders/q.match.$id.server.ts +++ b/app/features/sendouq-match/loaders/q.match.$id.server.ts @@ -1,6 +1,7 @@ import type { LoaderFunctionArgs } from "react-router"; import { getUser } from "~/features/auth/core/user.server"; import { chatAccessible } from "~/features/chat/chat-utils"; +import * as EventBus from "~/features/events/core/EventBus.server"; import * as Seasons from "~/features/mmr/core/Seasons"; import { resolveNotifications } from "~/features/notifications/core/resolve.server"; import * as ScannerIngestRepository from "~/features/scanner-ingest/ScannerIngestRepository.server"; @@ -65,13 +66,19 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { }); if (!accessible) return null; - if (!isParticipant) return match.chatCode ?? null; + if (!isParticipant) { + return match.chatRoomId + ? EventBus.chatRoomChannel(match.chatRoomId) + : null; + } const codes = [ - match.chatCode, - match.groupAlpha.chatCode, - match.groupBravo.chatCode, - ].filter((c): c is string => Boolean(c)); + match.chatRoomId, + match.groupAlpha.chatRoomId, + match.groupBravo.chatRoomId, + ] + .filter((id): id is number => typeof id === "number") + .map(EventBus.chatRoomChannel); if (codes.length === 0) return null; if (codes.length === 1) return codes[0]; diff --git a/app/features/sendouq/SQGroupRepository.server.test.ts b/app/features/sendouq/SQGroupRepository.server.test.ts index 675a2a41c..b260acfc1 100644 --- a/app/features/sendouq/SQGroupRepository.server.test.ts +++ b/app/features/sendouq/SQGroupRepository.server.test.ts @@ -31,7 +31,7 @@ const setupConcludedMatch = async ( return { alphaGroupId: match.alphaGroup.id, bravoGroupId: match.bravoGroup.id, - matchChatCode: match.chatCode, + matchChatRoomId: match.chatRoomId, alphaMembers, }; }; @@ -52,6 +52,18 @@ const setupTeam = async () => { return { team, members }; }; +const groupChatRoomId = async (groupId: number) => { + const group = await db + .selectFrom("Group") + .select("Group.chatRoomId") + .where("Group.id", "=", groupId) + .executeTakeFirstOrThrow(); + + return group.chatRoomId; +}; + +const allChatRooms = () => db.selectFrom("ChatRoom").selectAll().execute(); + const teamIdOfGroup = async (groupId: number) => { const group = await db .selectFrom("Group") @@ -63,8 +75,24 @@ const teamIdOfGroup = async (groupId: number) => { }; describe("insert", () => { + test("creates an SQ_GROUP chat room owned by the group", async () => { + const user = await UserFactory.create(); + + const result = await SQGroupRepository.insert({ + status: "PREPARING", + userId: user.id, + }); + + const chatRoomId = await groupChatRoomId(result.id); + expect(chatRoomId).toEqual(expect.any(Number)); + + const rooms = await allChatRooms(); + expect(rooms).toHaveLength(1); + expect(rooms[0].type).toBe("SQ_GROUP"); + }); + test("records implicit no-vote on previous matchmade group when user creates a new group", async () => { - const { alphaGroupId, alphaMembers, matchChatCode } = + const { alphaGroupId, alphaMembers, matchChatRoomId } = await setupConcludedMatch(); const votesBefore = await fetchVotes(alphaGroupId); @@ -79,11 +107,11 @@ describe("insert", () => { expect(votes).toHaveLength(1); expect(votes[0].userId).toBe(alphaMembers[0].id); expect(votes[0].isContinuing).toBe(false); - expect(result.chatCodeToRevalidate).toBe(matchChatCode); + expect(result.chatRoomIdToRevalidate).toBe(matchChatRoomId); }); test("overrides the user's own yes vote on the previous match", async () => { - const { alphaGroupId, alphaMembers, matchChatCode } = + const { alphaGroupId, alphaMembers, matchChatRoomId } = await setupConcludedMatch(); await castYesVote(alphaMembers[0].id, alphaGroupId); @@ -98,7 +126,7 @@ describe("insert", () => { const votes = await fetchVotes(alphaGroupId); expect(votes).toHaveLength(1); expect(votes[0].isContinuing).toBe(false); - expect(result.chatCodeToRevalidate).toBe(matchChatCode); + expect(result.chatRoomIdToRevalidate).toBe(matchChatRoomId); }); test("clears other members' yes votes on the previous group when recording implicit no", async () => { @@ -123,7 +151,7 @@ describe("insert", () => { test("records the implicit no-vote on the newest matchmade group of many", async () => { const { alphaGroupId: olderGroupId, alphaMembers } = await setupConcludedMatch(); - const { alphaGroupId: newerGroupId, matchChatCode } = + const { alphaGroupId: newerGroupId, matchChatRoomId } = await setupConcludedMatch(alphaMembers); const olderVotesBefore = await fetchVotes(olderGroupId); @@ -138,7 +166,7 @@ describe("insert", () => { expect(votes).toHaveLength(1); expect(votes[0].userId).toBe(alphaMembers[0].id); expect(votes[0].isContinuing).toBe(false); - expect(result.chatCodeToRevalidate).toBe(matchChatCode); + expect(result.chatRoomIdToRevalidate).toBe(matchChatRoomId); }); test("leaves the previous group's votes alone on a later, unrelated queue action", async () => { @@ -165,7 +193,7 @@ describe("insert", () => { expect(votes.filter((vote) => vote.isContinuing)).toHaveLength( FULL_GROUP_SIZE - 1, ); - expect(result.chatCodeToRevalidate).toBeNull(); + expect(result.chatRoomIdToRevalidate).toBeNull(); }); test("does not record any vote when user has no previous matchmade group", async () => { @@ -180,13 +208,13 @@ describe("insert", () => { result.id, ]); expect(allVotes).toHaveLength(0); - expect(result.chatCodeToRevalidate).toBeNull(); + expect(result.chatRoomIdToRevalidate).toBeNull(); }); }); describe("insertMember", () => { test("records implicit no-vote on previous matchmade group when user joins another group", async () => { - const { alphaGroupId, alphaMembers, matchChatCode } = + const { alphaGroupId, alphaMembers, matchChatRoomId } = await setupConcludedMatch(); const newOwner = await UserFactory.create(); @@ -195,7 +223,7 @@ describe("insertMember", () => { memberUserIds: [newOwner.id], }); - const { chatCodeToRevalidate } = await SQGroupRepository.insertMember( + const { chatRoomIdToRevalidate } = await SQGroupRepository.insertMember( newGroup.id, { userId: alphaMembers[0].id }, ); @@ -204,7 +232,7 @@ describe("insertMember", () => { expect(votes).toHaveLength(1); expect(votes[0].userId).toBe(alphaMembers[0].id); expect(votes[0].isContinuing).toBe(false); - expect(chatCodeToRevalidate).toBe(matchChatCode); + expect(chatRoomIdToRevalidate).toBe(matchChatRoomId); }); }); @@ -309,3 +337,59 @@ describe("syncTeamId", () => { expect(await teamIdOfGroup(newGroup.id)).toBeNull(); }); }); + +describe("insertFromPrevious", () => { + test("moves the previous group's chat room to the successor", async () => { + const { alphaGroupId, alphaMembers } = await setupConcludedMatch(); + const previousChatRoomId = await groupChatRoomId(alphaGroupId); + + const successor = await SQGroupRepository.insertFromPrevious({ + previousGroupId: alphaGroupId, + memberUserIds: alphaMembers.map((member) => member.id), + }); + + expect(await groupChatRoomId(successor.id)).toBe(previousChatRoomId); + expect(await groupChatRoomId(alphaGroupId)).toBeNull(); + }); +}); + +describe("morphGroups", () => { + test("gives the survivor a fresh chat room and deletes both old rooms", async () => { + const [userOne, userTwo] = await UserFactory.createMany(2); + const survivingGroup = await SQGroupFactory.create({ + memberUserIds: [userOne.id], + }); + const otherGroup = await SQGroupFactory.create({ + memberUserIds: [userTwo.id], + }); + const oldChatRoomId = await groupChatRoomId(survivingGroup.id); + + await SQGroupRepository.morphGroups({ + survivingGroupId: survivingGroup.id, + otherGroupId: otherGroup.id, + }); + + const survivorChatRoomId = await groupChatRoomId(survivingGroup.id); + expect(survivorChatRoomId).not.toBe(oldChatRoomId); + + const rooms = await allChatRooms(); + expect(rooms.map((room) => room.id)).toEqual([survivorChatRoomId]); + }); +}); + +describe("leaveGroup", () => { + test("deletes the group and its chat room when the last member leaves", async () => { + const user = await UserFactory.create(); + const group = await SQGroupFactory.create({ memberUserIds: [user.id] }); + + await SQGroupRepository.leaveGroup(user.id); + + const groupRow = await db + .selectFrom("Group") + .selectAll() + .where("id", "=", group.id) + .executeTakeFirst(); + expect(groupRow).toBeUndefined(); + expect(await allChatRooms()).toHaveLength(0); + }); +}); diff --git a/app/features/sendouq/SQGroupRepository.server.ts b/app/features/sendouq/SQGroupRepository.server.ts index fc428e664..2c5fe2c86 100644 --- a/app/features/sendouq/SQGroupRepository.server.ts +++ b/app/features/sendouq/SQGroupRepository.server.ts @@ -1,4 +1,4 @@ -import { sub } from "date-fns"; +import { addHours, sub } from "date-fns"; import { type ExpressionBuilder, type NotNull, @@ -9,6 +9,7 @@ import { db } from "~/db/sql"; import type { DB, Tables } from "~/db/tables"; import type { UserMapModePreferences } from "~/db/tables-json"; import { actorId } from "~/features/auth/core/user.server"; +import * as ChatRepository from "~/features/chat/ChatRepository.server"; import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates"; import { shortNanoid } from "~/utils/id"; import { @@ -22,6 +23,8 @@ import { userIsBanned } from "../ban/core/banned.server"; import { FULL_GROUP_SIZE } from "./q-constants"; import { SendouQError } from "./q-utils.server"; +const CHAT_ROOM_LIFESPAN_HOURS = 12; + export async function findMapModePreferencesByGroupId(groupId: number) { const group = await db .selectFrom("Group") @@ -74,7 +77,7 @@ export async function findCurrentGroups() { ) .select(({ eb }) => [ "Group.id", - "Group.chatCode", + "Group.chatRoomId", "Group.inviteCode", "Group.latestActionAt", "Group.status", @@ -112,11 +115,19 @@ type CreateGroupArgs = { }; export async function insert(args: CreateGroupArgs) { return db.transaction().execute(async (trx) => { + const chatRoom = await ChatRepository.insertRoom( + { + type: "SQ_GROUP", + expiresAt: addHours(new Date(), CHAT_ROOM_LIFESPAN_HOURS), + }, + trx, + ); + const createdGroup = await trx .insertInto("Group") .values({ inviteCode: shortNanoid(), - chatCode: shortNanoid(), + chatRoomId: chatRoom.id, status: args.status, }) .returning("id") @@ -134,12 +145,12 @@ export async function insert(args: CreateGroupArgs) { throw new SendouQError("Group has a member in multiple groups"); } - const chatCodeToRevalidate = await recordImplicitRejoinNoVote( + const chatRoomIdToRevalidate = await recordImplicitRejoinNoVote( args.userId, trx, ); - return { id: createdGroup.id, chatCodeToRevalidate }; + return { id: createdGroup.id, chatRoomIdToRevalidate }; }); } @@ -154,20 +165,48 @@ export async function insertFromPrevious( const status = args.status ?? "PREPARING"; return db.transaction().execute(async (trx) => { + const previousGroup = await trx + .selectFrom("Group") + .select(["Group.chatRoomId", "Group.matchmade"]) + .where("Group.id", "=", args.previousGroupId) + .executeTakeFirstOrThrow(); + + // the successor group carries the previous group's chat over; the room's + // unique owner index requires the previous group to release it first + let chatRoomId = previousGroup.chatRoomId; + if (chatRoomId !== null) { + await trx + .updateTable("Group") + .set({ chatRoomId: null }) + .where("Group.id", "=", args.previousGroupId) + .execute(); + await ChatRepository.updateRoomExpiresAt( + { + roomId: chatRoomId, + expiresAt: addHours(new Date(), CHAT_ROOM_LIFESPAN_HOURS), + }, + trx, + ); + } else { + chatRoomId = ( + await ChatRepository.insertRoom( + { + type: "SQ_GROUP", + expiresAt: addHours(new Date(), CHAT_ROOM_LIFESPAN_HOURS), + }, + trx, + ) + ).id; + } + const createdGroup = await trx .insertInto("Group") - .columns(["chatCode", "inviteCode", "status", "matchmade"]) - .expression((eb) => - eb - .selectFrom("Group") - .select((eb) => [ - "Group.chatCode", - eb.val(shortNanoid()).as("inviteCode"), - eb.val(status).as("status"), - "Group.matchmade", - ]) - .where("Group.id", "=", args.previousGroupId), - ) + .values({ + chatRoomId, + inviteCode: shortNanoid(), + status, + matchmade: previousGroup.matchmade, + }) .returning("id") .executeTakeFirstOrThrow(); @@ -281,10 +320,24 @@ export function morphGroups({ otherGroupId: number; }) { return db.transaction().execute(async (trx) => { - // reset chat code so previous messages are not visible, and mark as matchmade + const oldChatRooms = await trx + .selectFrom("Group") + .select(["Group.chatRoomId"]) + .where("Group.id", "in", [survivingGroupId, otherGroupId]) + .execute(); + + // fresh chat room so neither group's previous messages are visible, and + // mark as matchmade + const chatRoom = await ChatRepository.insertRoom( + { + type: "SQ_GROUP", + expiresAt: addHours(new Date(), CHAT_ROOM_LIFESPAN_HOURS), + }, + trx, + ); await trx .updateTable("Group") - .set({ chatCode: shortNanoid(), matchmade: 1 }) + .set({ chatRoomId: chatRoom.id, matchmade: 1 }) .where("Group.id", "=", survivingGroupId) .execute(); @@ -298,6 +351,11 @@ export function morphGroups({ await deleteLikesAndSuggestionsByGroupId(survivingGroupId, trx); await refreshGroup(survivingGroupId, trx); + await ChatRepository.deleteRoomsByIds( + oldChatRooms.map((room) => room.chatRoomId), + trx, + ); + await trx .deleteFrom("Group") .where("Group.id", "=", otherGroupId) @@ -348,7 +406,7 @@ export async function insertMember( groupId: number, { userId }: { userId: number }, ) { - const chatCodeToRevalidate = await db.transaction().execute(async (trx) => { + const chatRoomIdToRevalidate = await db.transaction().execute(async (trx) => { await trx .insertInto("GroupMember") .values({ @@ -369,7 +427,7 @@ export async function insertMember( return recordImplicitRejoinNoVote(userId, trx); }); - return { chatCodeToRevalidate }; + return { chatRoomIdToRevalidate }; } export async function findAllLikesByGroupId(groupId: number) { @@ -577,7 +635,10 @@ export async function closeExpiredContinueVotes() { .onRef("GroupMatchContinueVote.groupId", "=", "Group.id") .onRef("GroupMatchContinueVote.userId", "=", "GroupMember.userId"), ) - .select(["Group.id as groupId", "GroupMatch.chatCode as matchChatCode"]) + .select([ + "Group.id as groupId", + "GroupMatch.chatRoomId as matchChatRoomId", + ]) .where("Group.matchmade", "=", 1) .where("GroupMatch.confirmedAt", "is not", null) .where("GroupMatch.confirmedAt", "<", cutoff) @@ -585,9 +646,9 @@ export async function closeExpiredContinueVotes() { .groupBy("Group.id") .execute(); - const chatCodesToRevalidate = eligibleGroups - .map((group) => group.matchChatCode) - .filter((chatCode) => chatCode !== null); + const chatRoomIdsToRevalidate = eligibleGroups + .map((group) => group.matchChatRoomId) + .filter((chatRoomId) => chatRoomId !== null); if (eligibleGroups.length > 0) { const members = await trx @@ -616,7 +677,7 @@ export async function closeExpiredContinueVotes() { } return { - chatCodesToRevalidate, + chatRoomIdsToRevalidate, numAffectedGroups: eligibleGroups.length, }; }); @@ -768,7 +829,7 @@ export function leaveGroup(userId: number) { const userGroup = await trx .selectFrom("GroupMember") .innerJoin("Group", "Group.id", "GroupMember.groupId") - .select(["Group.id"]) + .select(["Group.id", "Group.chatRoomId"]) .where("userId", "=", userId) .where("Group.status", "!=", "INACTIVE") .executeTakeFirstOrThrow(); @@ -814,6 +875,7 @@ export function leaveGroup(userId: number) { .executeTakeFirst(); if (!remainingMember) { + await ChatRepository.deleteRoomsByIds([userGroup.chatRoomId], trx); await trx.deleteFrom("Group").where("id", "=", userGroup.id).execute(); return { abortedReadyCheckGroupIds }; } @@ -1124,7 +1186,7 @@ async function deleteReadyCheckInTrx( async function recordImplicitRejoinNoVote( userId: number, trx: Transaction, -): Promise { +): Promise { const candidate = await trx .selectFrom("GroupMember") .innerJoin("Group", "Group.id", "GroupMember.groupId") @@ -1138,7 +1200,7 @@ async function recordImplicitRejoinNoVote( ) .select((eb) => [ "Group.id as groupId", - "GroupMatch.chatCode as matchChatCode", + "GroupMatch.chatRoomId as matchChatRoomId", hasVotedNo(eb, userId).as("alreadySettled"), ]) .where("GroupMember.userId", "=", userId) @@ -1168,7 +1230,7 @@ async function recordImplicitRejoinNoVote( ) .execute(); - return candidate.matchChatCode; + return candidate.matchChatRoomId; } /** Matches the `Group` rows the given user has already voted against continuing with. */ diff --git a/app/features/sendouq/actions/q.looking.server.ts b/app/features/sendouq/actions/q.looking.server.ts index 62fd2a625..2e2846fd1 100644 --- a/app/features/sendouq/actions/q.looking.server.ts +++ b/app/features/sendouq/actions/q.looking.server.ts @@ -2,6 +2,7 @@ import type { ActionFunction } from "react-router"; import { redirect } from "react-router"; import { requireUser } from "~/features/auth/core/user.server"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; +import * as EventBus from "~/features/events/core/EventBus.server"; import * as Seasons from "~/features/mmr/core/Seasons"; import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; import { parseFormData } from "~/form/parse.server"; @@ -150,18 +151,22 @@ export const action: ActionFunction = async ({ request }) => { await refreshSendouQInstance(); - if (ourGroup.chatCode) { - ChatSystemMessage.removeRoom(ourGroup.chatCode); + if (ourGroup.chatRoomId) { + ChatSystemMessage.removeRoom( + EventBus.chatRoomChannel(ourGroup.chatRoomId), + ); } - if (theirGroup.chatCode) { - ChatSystemMessage.removeRoom(theirGroup.chatCode); + if (theirGroup.chatRoomId) { + ChatSystemMessage.removeRoom( + EventBus.chatRoomChannel(theirGroup.chatRoomId), + ); } const survivingGroup = SendouQ.findUncensoredGroupById(survivingGroupId); - if (survivingGroup?.chatCode) { + if (survivingGroup?.chatRoomId) { setGroupChatMetadata({ - chatCode: survivingGroup.chatCode, + chatRoomId: survivingGroup.chatRoomId, members: survivingGroup.members, }); } @@ -209,14 +214,14 @@ export const action: ActionFunction = async ({ request }) => { } const remainingGroup = SendouQ.findUncensoredGroupById(currentGroup.id); - if (remainingGroup?.chatCode) { + if (remainingGroup?.chatRoomId) { ChatSystemMessage.send({ - room: remainingGroup.chatCode, + room: EventBus.chatRoomChannel(remainingGroup.chatRoomId), type: "USER_LEFT", context: { name: user.username }, }); setGroupChatMetadata({ - chatCode: remainingGroup.chatCode, + chatRoomId: remainingGroup.chatRoomId, members: remainingGroup.members, }); } @@ -245,14 +250,14 @@ export const action: ActionFunction = async ({ request }) => { await refreshSendouQInstance(); const groupAfterKick = SendouQ.findUncensoredGroupById(currentGroup.id); - if (groupAfterKick?.chatCode && kickedMember) { + if (groupAfterKick?.chatRoomId && kickedMember) { ChatSystemMessage.send({ - room: groupAfterKick.chatCode, + room: EventBus.chatRoomChannel(groupAfterKick.chatRoomId), type: "USER_LEFT", context: { name: kickedMember.username }, }); setGroupChatMetadata({ - chatCode: groupAfterKick.chatCode, + chatRoomId: groupAfterKick.chatRoomId, members: groupAfterKick.members, }); } diff --git a/app/features/sendouq/actions/q.preparing.server.ts b/app/features/sendouq/actions/q.preparing.server.ts index e7c16f79b..991736e22 100644 --- a/app/features/sendouq/actions/q.preparing.server.ts +++ b/app/features/sendouq/actions/q.preparing.server.ts @@ -2,6 +2,7 @@ import type { ActionFunctionArgs } from "react-router"; import { redirect } from "react-router"; import { requireUser } from "~/features/auth/core/user.server"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; +import * as EventBus from "~/features/events/core/EventBus.server"; import * as Seasons from "~/features/mmr/core/Seasons"; import { notify } from "~/features/notifications/core/notify.server"; import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; @@ -60,14 +61,14 @@ export const action = async ({ request }: ActionFunctionArgs) => { "User you are trying to add has no friend code set", ); - const { chatCodeToRevalidate } = await SQGroupRepository.insertMember( + const { chatRoomIdToRevalidate } = await SQGroupRepository.insertMember( ownGroup.id, { userId: data.id }, ); - if (chatCodeToRevalidate) { + if (chatRoomIdToRevalidate) { ChatSystemMessage.send({ - room: chatCodeToRevalidate, + room: EventBus.chatRoomChannel(chatRoomIdToRevalidate), revalidateOnly: true, }); } @@ -75,9 +76,9 @@ export const action = async ({ request }: ActionFunctionArgs) => { await refreshSendouQInstance(); const updatedGroup = SendouQ.findOwnGroup(user.id); - if (updatedGroup?.chatCode) { + if (updatedGroup?.chatRoomId) { setGroupChatMetadata({ - chatCode: updatedGroup.chatCode, + chatRoomId: updatedGroup.chatRoomId, members: updatedGroup.members, }); } diff --git a/app/features/sendouq/actions/q.server.ts b/app/features/sendouq/actions/q.server.ts index 0dd8f607b..32f7ffb82 100644 --- a/app/features/sendouq/actions/q.server.ts +++ b/app/features/sendouq/actions/q.server.ts @@ -4,6 +4,7 @@ import * as AdminRepository from "~/features/admin/AdminRepository.server"; import { requireUser } from "~/features/auth/core/user.server"; import { refreshBannedCache } from "~/features/ban/core/banned.server"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; +import * as EventBus from "~/features/events/core/EventBus.server"; import * as Seasons from "~/features/mmr/core/Seasons"; import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; @@ -54,14 +55,14 @@ export const action: ActionFunction = async ({ request, url }) => { await validateCanJoinQ(user); - const { chatCodeToRevalidate } = await SQGroupRepository.insert({ + const { chatRoomIdToRevalidate } = await SQGroupRepository.insert({ status: data.direct === "true" ? "ACTIVE" : "PREPARING", userId: user.id, }); - if (chatCodeToRevalidate) { + if (chatRoomIdToRevalidate) { ChatSystemMessage.send({ - room: chatCodeToRevalidate, + room: EventBus.chatRoomChannel(chatRoomIdToRevalidate), revalidateOnly: true, }); } @@ -95,14 +96,14 @@ export const action: ActionFunction = async ({ request, url }) => { "Invite code doesn't match any active team", ); - const { chatCodeToRevalidate } = await SQGroupRepository.insertMember( + const { chatRoomIdToRevalidate } = await SQGroupRepository.insertMember( groupInvitedTo.id, { userId: user.id }, ); - if (chatCodeToRevalidate) { + if (chatRoomIdToRevalidate) { ChatSystemMessage.send({ - room: chatCodeToRevalidate, + room: EventBus.chatRoomChannel(chatRoomIdToRevalidate), revalidateOnly: true, }); } @@ -110,9 +111,9 @@ export const action: ActionFunction = async ({ request, url }) => { await refreshSendouQInstance(); const joinedGroup = SendouQ.findOwnGroup(user.id); - if (joinedGroup?.chatCode) { + if (joinedGroup?.chatRoomId) { setGroupChatMetadata({ - chatCode: joinedGroup.chatCode, + chatRoomId: joinedGroup.chatRoomId, members: joinedGroup.members, }); } diff --git a/app/features/sendouq/components/GroupCard.browser.test.tsx b/app/features/sendouq/components/GroupCard.browser.test.tsx index 3405fefc4..3fd27078c 100644 --- a/app/features/sendouq/components/GroupCard.browser.test.tsx +++ b/app/features/sendouq/components/GroupCard.browser.test.tsx @@ -113,7 +113,7 @@ function createOwnGroup( noScreen: false, modePreferences: [], teamMapModePreferences: undefined, - chatCode: null, + chatRoomId: null, status: "ACTIVE", matchId: null, inviteCode: "test123", diff --git a/app/features/sendouq/core/SendouQ.server.ts b/app/features/sendouq/core/SendouQ.server.ts index abc3f6b02..160685650 100644 --- a/app/features/sendouq/core/SendouQ.server.ts +++ b/app/features/sendouq/core/SendouQ.server.ts @@ -186,7 +186,7 @@ class SendouQClass { ) => { return { ...R.omit(group, ["tierName", "tierIsPlus"]), - chatCode: isTeamMember ? group.chatCode : undefined, + chatRoomId: isTeamMember ? group.chatRoomId : undefined, tier: SendouQMatch.groupTier(group), skillDifference: match.skillDifferences.groups[group.id], matchmade: Boolean(group.matchmade), @@ -236,7 +236,7 @@ class SendouQClass { return { ...match, - chatCode: isMatchInsider ? match.chatCode : undefined, + chatRoomId: isMatchInsider ? match.chatRoomId : undefined, noScreen: Boolean(match.noScreen), currentMap, groupAlpha: alphaCensored, @@ -374,12 +374,12 @@ class SendouQClass { #censorGroup( group: T, - ): Omit & { + ): Omit & { members: T["members"] | undefined; } { const { inviteCode: _inviteCode, - chatCode: _chatCode, + chatRoomId: _chatRoomId, members, ...baseGroup } = group; diff --git a/app/features/sendouq/core/ready-check.server.ts b/app/features/sendouq/core/ready-check.server.ts index 8dcdb2b6f..7ca0139ec 100644 --- a/app/features/sendouq/core/ready-check.server.ts +++ b/app/features/sendouq/core/ready-check.server.ts @@ -26,7 +26,7 @@ export type ReadyCheck = NonNullable< type ReadyCheckGroup = { id: number; - chatCode: string | null; + chatRoomId: number | null; members: Array<{ id: number }>; }; @@ -67,9 +67,9 @@ export async function start({ // extend the group chat rooms' expiry so they last through the match for (const group of [ownGroup, theirGroup]) { - if (group.chatCode) { + if (group.chatRoomId) { setGroupChatMetadata({ - chatCode: group.chatCode, + chatRoomId: group.chatRoomId, members: group.members, }); } @@ -245,10 +245,10 @@ async function createMatch({ await refreshSendouQInstance(); refreshStreamsCache(); - if (createdMatch.chatCode) { + if (createdMatch.chatRoomId) { setMatchChatMetadata({ id: createdMatch.id, - chatCode: createdMatch.chatCode, + chatRoomId: createdMatch.chatRoomId, participantUserIds: readyCheck.members.map((member) => member.userId), }); } diff --git a/app/features/sendouq/loaders/q.looking.server.ts b/app/features/sendouq/loaders/q.looking.server.ts index d749b0399..efa31f692 100644 --- a/app/features/sendouq/loaders/q.looking.server.ts +++ b/app/features/sendouq/loaders/q.looking.server.ts @@ -1,6 +1,7 @@ import type { LoaderFunctionArgs } from "react-router"; import * as R from "remeda"; import { requireUser } from "~/features/auth/core/user.server"; +import * as EventBus from "~/features/events/core/EventBus.server"; import { resolveNotifications } from "~/features/notifications/core/resolve.server"; import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; import { cachedStreams } from "~/features/sendouq-streams/core/streams.server"; @@ -70,6 +71,8 @@ export const loader = async ({ url }: LoaderFunctionArgs) => { lastUpdated: Date.now(), streamsCount: (await cachedStreams()).length, chatCode: - ownGroup && ownGroup.members.length > 1 ? ownGroup.chatCode : null, + ownGroup && ownGroup.members.length > 1 && ownGroup.chatRoomId !== null + ? EventBus.chatRoomChannel(ownGroup.chatRoomId) + : null, }; }; diff --git a/app/features/sendouq/q-utils.server.ts b/app/features/sendouq/q-utils.server.ts index 0f6a2cd48..42a615350 100644 --- a/app/features/sendouq/q-utils.server.ts +++ b/app/features/sendouq/q-utils.server.ts @@ -1,4 +1,5 @@ import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; +import * as EventBus from "~/features/events/core/EventBus.server"; import { TIERS } from "~/features/mmr/mmr-constants"; import * as SkillRepository from "~/features/mmr/SkillRepository.server"; import type { TieredSkill } from "~/features/mmr/tiered.server"; @@ -38,11 +39,11 @@ export function clearSeasonSkillsCache() { } export function setGroupChatMetadata(group: { - chatCode: string; + chatRoomId: number; members: { id: number }[]; }) { ChatSystemMessage.setMetadata({ - chatCode: group.chatCode, + chatCode: EventBus.chatRoomChannel(group.chatRoomId), header: `Group (${group.members.length}/4)`, subtitle: "SendouQ", url: SENDOUQ_LOOKING_PAGE, @@ -54,11 +55,11 @@ export function setGroupChatMetadata(group: { export function setMatchChatMetadata(match: { id: number; - chatCode: string; + chatRoomId: number; participantUserIds: number[]; }) { ChatSystemMessage.setMetadata({ - chatCode: match.chatCode, + chatCode: EventBus.chatRoomChannel(match.chatRoomId), header: `Match #${match.id}`, subtitle: "SendouQ", url: sendouQMatchPage(match.id), diff --git a/app/features/tournament-admin/actions/to.$id.admin.index.server.ts b/app/features/tournament-admin/actions/to.$id.admin.index.server.ts index a628143c7..fcb961b8a 100644 --- a/app/features/tournament-admin/actions/to.$id.admin.index.server.ts +++ b/app/features/tournament-admin/actions/to.$id.admin.index.server.ts @@ -2,6 +2,7 @@ import type { ActionFunction } from "react-router"; import * as R from "remeda"; import { db } from "~/db/sql"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; +import * as EventBus from "~/features/events/core/EventBus.server"; import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; import { resolveNotifications } from "~/features/notifications/core/resolve.server"; import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; @@ -105,7 +106,9 @@ export const action: ActionFunction = async ({ request, params }) => { await TournamentTeamRepository.deleteById(team.id); if (pickupChatTeam) { - ChatSystemMessage.removeRoom(pickupChatTeam.chatCode); + ChatSystemMessage.removeRoom( + EventBus.chatRoomChannel(pickupChatTeam.chatRoomId), + ); } for (const userId of team.memberUserIds) { diff --git a/app/features/tournament-bracket/BracketRepository.server.ts b/app/features/tournament-bracket/BracketRepository.server.ts index b6a89af67..7300c1433 100644 --- a/app/features/tournament-bracket/BracketRepository.server.ts +++ b/app/features/tournament-bracket/BracketRepository.server.ts @@ -1,8 +1,9 @@ +import { addDays } from "date-fns"; import { sql as kyselySql, type RawBuilder, type Transaction } from "kysely"; import { db } from "~/db/sql"; import type { DB } from "~/db/tables"; +import * as ChatRepository from "~/features/chat/ChatRepository.server"; import { databaseTimestampNow } from "~/utils/dates"; -import { shortNanoid } from "~/utils/id"; import { jsonArrayFrom } from "~/utils/kysely.server"; import { matchStatuses } from "./core/engine/status"; import type { @@ -12,6 +13,8 @@ import type { ParticipantResult, } from "./core/engine/types"; +const CHAT_ROOM_LIFESPAN_DAYS = 7; + /** * Loads the full BracketData for a tournament (all stages). Includes the * score/totalKos aggregation over TournamentMatchGameResult. Direct replacement @@ -204,6 +207,15 @@ export function insertBracket(args: { const statuses = matchStatuses(args.bracket); + // only matches that can already be played get a chat room; the rest get + // theirs as they start (see syncStartedAt) + const chatRoomIdByMatchId = new Map(); + for (const match of args.bracket.match) { + if (statuses.get(match.id) === "STARTED") { + chatRoomIdByMatchId.set(match.id, await insertMatchChatRoom(trx)); + } + } + await trx .insertInto("TournamentMatch") .values( @@ -215,7 +227,7 @@ export function insertBracket(args: { opponentOne: serializeOpponent(match.opponent1), opponentTwo: serializeOpponent(match.opponent2), winnerSide: match.winnerSide, - chatCode: shortNanoid(), + chatRoomId: chatRoomIdByMatchId.get(match.id) ?? null, startedAt: statuses.get(match.id) === "STARTED" ? databaseTimestampNow() @@ -290,6 +302,21 @@ async function syncStartedAt( .set({ startedAt: databaseTimestampNow() }) .where("id", "in", startedMatchIds) .execute(); + + // a match reverted to pending keeps its room, so only fill the gaps + const roomlessMatches = await trx + .selectFrom("TournamentMatch") + .select(["TournamentMatch.id"]) + .where("TournamentMatch.id", "in", startedMatchIds) + .where("TournamentMatch.chatRoomId", "is", null) + .execute(); + for (const match of roomlessMatches) { + await trx + .updateTable("TournamentMatch") + .set({ chatRoomId: await insertMatchChatRoom(trx) }) + .where("TournamentMatch.id", "=", match.id) + .execute(); + } } if (pendingMatchIds.length > 0) { @@ -315,10 +342,19 @@ export async function insertRoundMatches( const executor = trx ?? db; + const chatRoomIds: Array = []; + for (const match of args.round.matches) { + chatRoomIds.push( + match.opponent1?.id && match.opponent2?.id + ? await insertMatchChatRoom(trx) + : null, + ); + } + await executor .insertInto("TournamentMatch") .values( - args.round.matches.map((match) => ({ + args.round.matches.map((match, i) => ({ stageId: args.stageId, groupId: args.round.groupId, roundId: args.round.roundId, @@ -326,7 +362,7 @@ export async function insertRoundMatches( opponentOne: serializeOpponent(match.opponent1), opponentTwo: serializeOpponent(match.opponent2), winnerSide: null, - chatCode: shortNanoid(), + chatRoomId: chatRoomIds[i], // swiss rounds are only generated once they can be played startedAt: match.opponent1?.id && match.opponent2?.id @@ -342,16 +378,39 @@ export async function deleteRoundMatches(args: { groupId: number; roundId: number; }): Promise { - await db - .deleteFrom("TournamentMatch") - .where("groupId", "=", args.groupId) - .where("roundId", "=", args.roundId) - .execute(); + await db.transaction().execute(async (trx) => { + const matches = await trx + .selectFrom("TournamentMatch") + .select(["TournamentMatch.chatRoomId"]) + .where("groupId", "=", args.groupId) + .where("roundId", "=", args.roundId) + .execute(); + await ChatRepository.deleteRoomsByIds( + matches.map((match) => match.chatRoomId), + trx, + ); + + await trx + .deleteFrom("TournamentMatch") + .where("groupId", "=", args.groupId) + .where("roundId", "=", args.roundId) + .execute(); + }); } /** Deletes the whole stage subtree (matches, rounds, groups, stage). */ export function resetBracket(tournamentStageId: number) { return db.transaction().execute(async (trx) => { + const matches = await trx + .selectFrom("TournamentMatch") + .select(["TournamentMatch.chatRoomId"]) + .where("stageId", "=", tournamentStageId) + .execute(); + await ChatRepository.deleteRoomsByIds( + matches.map((match) => match.chatRoomId), + trx, + ); + await trx .deleteFrom("TournamentMatch") .where("stageId", "=", tournamentStageId) @@ -382,6 +441,17 @@ function serializeOpponent(opponent: ParticipantResult | null): string | null { return JSON.stringify(persisted); } +async function insertMatchChatRoom(trx?: Transaction) { + const room = await ChatRepository.insertRoom( + { + type: "TOURNAMENT_MATCH", + expiresAt: addDays(new Date(), CHAT_ROOM_LIFESPAN_DAYS), + }, + trx, + ); + return room.id; +} + /** * Lines the ids of a multi-row insert back up with the rows they were inserted for. SQLite assigns * ids in insertion order, but RETURNING makes no ordering promise, so the ids are sorted first. diff --git a/app/features/tournament-lfg/TournamentLFGRepository.server.test.ts b/app/features/tournament-lfg/TournamentLFGRepository.server.test.ts index 4d5e89d6e..be4ffd410 100644 --- a/app/features/tournament-lfg/TournamentLFGRepository.server.test.ts +++ b/app/features/tournament-lfg/TournamentLFGRepository.server.test.ts @@ -1,3 +1,4 @@ +import { addDays } from "date-fns"; import { beforeEach, describe, expect, test } from "vitest"; import * as TournamentFactory from "~/db/seed/factories/TournamentFactory"; import * as TournamentLFGTeamFactory from "~/db/seed/factories/TournamentLFGTeamFactory"; @@ -16,6 +17,22 @@ const createTournament = () => const createPlaceholder = (tournamentId: number, userId: number) => TournamentLFGTeamFactory.create({ tournamentId, userId }); +const startLooking = (teamId: number) => + TournamentLFGRepository.startLooking({ + teamId, + chatRoomExpiresAt: addDays(new Date(), 7), + }); + +const mergeTeams = (args: { + survivingTeamId: number; + otherTeamId: number; + maxGroupSize: number; +}) => + TournamentLFGRepository.mergeTeams({ + ...args, + chatRoomExpiresAt: addDays(new Date(), 7), + }); + describe("insertPlaceholderTeam", () => { beforeEach(async () => { await users.create(2); @@ -211,27 +228,34 @@ describe("startLooking", () => { await users.create(3); }); - test("generates chatCode for a 2+ member team", async () => { + test("creates a chat room for a 2+ member team", async () => { const tournament = await createTournament(); const team = await TournamentTeamFactory.create({ tournamentId: tournament.id, memberUserIds: [users.id(1), users.id(2)], }); - const pickup = await TournamentLFGRepository.startLooking(team.id); + const pickup = await startLooking(team.id); expect(pickup).not.toBeNull(); - expect(pickup?.chatCode).toMatch(/.+/); + expect(pickup?.chatRoomId).toEqual(expect.any(Number)); expect(pickup?.memberUserIds.sort()).toEqual( [users.id(1), users.id(2)].sort(), ); const row = await db .selectFrom("TournamentTeam") - .select("chatCode") + .select("chatRoomId") .where("id", "=", team.id) .executeTakeFirstOrThrow(); - expect(row.chatCode).toBe(pickup?.chatCode); + expect(row.chatRoomId).toBe(pickup?.chatRoomId); + + const room = await db + .selectFrom("ChatRoom") + .selectAll() + .where("id", "=", pickup!.chatRoomId) + .executeTakeFirstOrThrow(); + expect(room.type).toBe("TOURNAMENT_TEAM"); }); test("returns null when team has only 1 member", async () => { @@ -241,36 +265,29 @@ describe("startLooking", () => { memberUserIds: [users.id(1)], }); - const pickup = await TournamentLFGRepository.startLooking(team.id); + const pickup = await startLooking(team.id); expect(pickup).toBeNull(); const row = await db .selectFrom("TournamentTeam") - .select("chatCode") + .select("chatRoomId") .where("id", "=", team.id) .executeTakeFirstOrThrow(); - expect(row.chatCode).toBeNull(); + expect(row.chatRoomId).toBeNull(); }); - test("reuses existing chatCode if already set", async () => { + test("reuses the existing chat room if already set", async () => { const tournament = await createTournament(); const team = await TournamentTeamFactory.create({ tournamentId: tournament.id, memberUserIds: [users.id(1), users.id(2)], }); - // the only production write of the column is `startLooking` itself, which - // invents a random code - // biome-ignore lint/plugin: no production write sets a known chatCode - await db - .updateTable("TournamentTeam") - .set({ chatCode: "existing-code" }) - .where("id", "=", team.id) - .execute(); - const pickup = await TournamentLFGRepository.startLooking(team.id); + const first = await startLooking(team.id); + const second = await startLooking(team.id); - expect(pickup?.chatCode).toBe("existing-code"); + expect(second?.chatRoomId).toBe(first?.chatRoomId); }); }); @@ -284,7 +301,7 @@ describe("mergeTeams", () => { const team1 = await createPlaceholder(tournament.id, users.id(1)); const team2 = await createPlaceholder(tournament.id, users.id(2)); - await TournamentLFGRepository.mergeTeams({ + await mergeTeams({ survivingTeamId: team1.id, otherTeamId: team2.id, maxGroupSize: 4, @@ -305,7 +322,7 @@ describe("mergeTeams", () => { const team1 = await createPlaceholder(tournament.id, users.id(1)); const team2 = await createPlaceholder(tournament.id, users.id(2)); - await TournamentLFGRepository.mergeTeams({ + await mergeTeams({ survivingTeamId: team1.id, otherTeamId: team2.id, maxGroupSize: 4, @@ -326,7 +343,7 @@ describe("mergeTeams", () => { const team2 = await createPlaceholder(tournament.id, users.id(2)); await expect( - TournamentLFGRepository.mergeTeams({ + mergeTeams({ survivingTeamId: team1.id, otherTeamId: team2.id, maxGroupSize: 1, @@ -339,7 +356,7 @@ describe("mergeTeams", () => { const team1 = await createPlaceholder(tournament.id, users.id(1)); const team2 = await createPlaceholder(tournament.id, users.id(2)); - await TournamentLFGRepository.mergeTeams({ + await mergeTeams({ survivingTeamId: team1.id, otherTeamId: team2.id, maxGroupSize: 2, @@ -352,51 +369,54 @@ describe("mergeTeams", () => { expect(groups).toHaveLength(0); }); - test("survivor gets a chatCode when merged size is 2+", async () => { + test("survivor gets a chat room when merged size is 2+", async () => { const tournament = await createTournament(); const team1 = await createPlaceholder(tournament.id, users.id(1)); const team2 = await createPlaceholder(tournament.id, users.id(2)); - const result = await TournamentLFGRepository.mergeTeams({ + const result = await mergeTeams({ survivingTeamId: team1.id, otherTeamId: team2.id, maxGroupSize: 4, }); expect(result.survivor).not.toBeNull(); - expect(result.survivor?.chatCode).toMatch(/.+/); + expect(result.survivor?.chatRoomId).toEqual(expect.any(Number)); expect(result.survivor?.memberUserIds.sort()).toEqual( [users.id(1), users.id(2)].sort(), ); - expect(result.removedChatCode).toBeNull(); + expect(result.removedChatRoomId).toBeNull(); const row = await db .selectFrom("TournamentTeam") - .select("chatCode") + .select("chatRoomId") .where("id", "=", team1.id) .executeTakeFirstOrThrow(); - expect(row.chatCode).toBe(result.survivor?.chatCode); + expect(row.chatRoomId).toBe(result.survivor?.chatRoomId); }); - test("returns removedChatCode when other team had a chatCode", async () => { + test("deletes the other team's chat room and returns its id", async () => { const tournament = await createTournament(); const team1 = await createPlaceholder(tournament.id, users.id(1)); - const team2 = await createPlaceholder(tournament.id, users.id(2)); + const team2 = await TournamentTeamFactory.create({ + tournamentId: tournament.id, + memberUserIds: [users.id(2), users.id(3)], + }); + const otherPickup = await startLooking(team2.id); - // biome-ignore lint/plugin: as above, a known chatCode has no production write - await db - .updateTable("TournamentTeam") - .set({ chatCode: "other-code" }) - .where("id", "=", team2.id) - .execute(); - - const result = await TournamentLFGRepository.mergeTeams({ + const result = await mergeTeams({ survivingTeamId: team1.id, otherTeamId: team2.id, maxGroupSize: 4, }); - expect(result.removedChatCode).toBe("other-code"); + expect(result.removedChatRoomId).toBe(otherPickup?.chatRoomId); + + // the loser's room is gone; only the survivor's (possibly reusing the + // freed rowid) remains + const rooms = await db.selectFrom("ChatRoom").select("id").execute(); + expect(rooms).toHaveLength(1); + expect(rooms[0].id).toBe(result.survivor?.chatRoomId); }); test("clears likes on surviving team after merge", async () => { @@ -414,7 +434,7 @@ describe("mergeTeams", () => { targetTeamId: team1.id, }); - await TournamentLFGRepository.mergeTeams({ + await mergeTeams({ survivingTeamId: team1.id, otherTeamId: team2.id, maxGroupSize: 4, @@ -441,7 +461,7 @@ describe("mergeTeams", () => { .where("userId", "=", users.id(2)) .execute(); - await TournamentLFGRepository.mergeTeams({ + await mergeTeams({ survivingTeamId: team1.id, otherTeamId: team2.id, maxGroupSize: 4, @@ -620,7 +640,7 @@ describe("findPickupChatTeamById", () => { await users.create(3); }); - test("returns null when team has no chatCode", async () => { + test("returns null when team has no chat room", async () => { const tournament = await createTournament(); const team = await TournamentTeamFactory.create({ tournamentId: tournament.id, @@ -644,7 +664,7 @@ describe("findPickupChatTeamById", () => { tournamentId: tournament.id, memberUserIds: [users.id(1), users.id(2), users.id(3)], }); - const pickup = await TournamentLFGRepository.startLooking(team.id); + const pickup = await startLooking(team.id); await withUserId(users.id(3), () => TournamentTeamRepository.leave({ @@ -657,7 +677,7 @@ describe("findPickupChatTeamById", () => { team.id, ); - expect(chatTeam?.chatCode).toBe(pickup?.chatCode); + expect(chatTeam?.chatRoomId).toBe(pickup?.chatRoomId); expect(chatTeam?.memberUserIds.sort()).toEqual( [users.id(1), users.id(2)].sort(), ); diff --git a/app/features/tournament-lfg/TournamentLFGRepository.server.ts b/app/features/tournament-lfg/TournamentLFGRepository.server.ts index a48602c24..d1ff80722 100644 --- a/app/features/tournament-lfg/TournamentLFGRepository.server.ts +++ b/app/features/tournament-lfg/TournamentLFGRepository.server.ts @@ -2,6 +2,7 @@ import type { ExpressionBuilder, 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 { databaseTimestampNow } from "~/utils/dates"; import { shortNanoid } from "~/utils/id"; import invariant from "~/utils/invariant"; @@ -13,15 +14,18 @@ import { import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql"; import { randomTeamName } from "~/utils/team-name"; -export function startLooking(teamId: number) { +export function startLooking(args: { + teamId: number; + chatRoomExpiresAt: Date; +}) { return db.transaction().execute(async (trx) => { await trx .updateTable("TournamentTeam") .set({ isLooking: 1 }) - .where("id", "=", teamId) + .where("id", "=", args.teamId) .execute(); - return ensurePickupChatCode(teamId, trx); + return ensurePickupChatRoom(args.teamId, args.chatRoomExpiresAt, trx); }); } @@ -119,15 +123,17 @@ export function mergeTeams({ survivingTeamId, otherTeamId, maxGroupSize, + chatRoomExpiresAt, }: { survivingTeamId: number; otherTeamId: number; maxGroupSize: number; + chatRoomExpiresAt: Date; }) { return db.transaction().execute(async (trx) => { const otherTeam = await trx .selectFrom("TournamentTeam") - .select("chatCode") + .select("chatRoomId") .where("id", "=", otherTeamId) .executeTakeFirst(); @@ -153,6 +159,8 @@ export function mergeTeams({ await deleteLikesByTeamId(survivingTeamId, trx); + await ChatRepository.deleteRoomsByIds([otherTeam?.chatRoomId ?? null], trx); + await trx .deleteFrom("TournamentTeam") .where("TournamentTeam.id", "=", otherTeamId) @@ -174,11 +182,15 @@ export function mergeTeams({ .where("id", "=", survivingTeamId) .execute(); - const survivor = await ensurePickupChatCode(survivingTeamId, trx); + const survivor = await ensurePickupChatRoom( + survivingTeamId, + chatRoomExpiresAt, + trx, + ); return { survivor, - removedChatCode: otherTeam?.chatCode ?? null, + removedChatRoomId: otherTeam?.chatRoomId ?? null, }; }); } @@ -310,6 +322,7 @@ export function leaveLfg({ .select([ "TournamentTeamMember.tournamentTeamId", "TournamentTeam.isPlaceholder", + "TournamentTeam.chatRoomId", ]) .where("TournamentTeamMember.userId", "=", userId) .where("TournamentTeam.tournamentId", "=", tournamentId) @@ -333,6 +346,8 @@ export function leaveLfg({ return; } + await ChatRepository.deleteRoomsByIds([userTeam.chatRoomId], trx); + await trx .deleteFrom("TournamentTeam") .where("id", "=", userTeam.tournamentTeamId) @@ -346,11 +361,11 @@ export async function findPickupChatTeamById( ): Promise { const team = await db .selectFrom("TournamentTeam") - .select(["name", "chatCode"]) + .select(["name", "chatRoomId"]) .where("id", "=", teamId) .executeTakeFirst(); - if (!team?.chatCode) return null; + if (team?.chatRoomId == null) return null; const members = await db .selectFrom("TournamentTeamMember") @@ -359,7 +374,7 @@ export async function findPickupChatTeamById( .execute(); return { - chatCode: team.chatCode, + chatRoomId: team.chatRoomId, name: team.name, memberUserIds: members.map((m) => m.userId), }; @@ -421,18 +436,19 @@ async function getMemberCount( } export type PickupChatTeam = { - chatCode: string; + chatRoomId: number; name: string; memberUserIds: number[]; }; -async function ensurePickupChatCode( +async function ensurePickupChatRoom( teamId: number, + chatRoomExpiresAt: Date, trx: Transaction, ): Promise { const team = await trx .selectFrom("TournamentTeam") - .select(["name", "chatCode"]) + .select(["name", "chatRoomId"]) .where("id", "=", teamId) .executeTakeFirstOrThrow(); @@ -444,18 +460,23 @@ async function ensurePickupChatCode( if (members.length < 2) return null; - let chatCode = team.chatCode; - if (!chatCode) { - chatCode = shortNanoid(); + let chatRoomId = team.chatRoomId; + if (chatRoomId == null) { + chatRoomId = ( + await ChatRepository.insertRoom( + { type: "TOURNAMENT_TEAM", expiresAt: chatRoomExpiresAt }, + trx, + ) + ).id; await trx .updateTable("TournamentTeam") - .set({ chatCode }) + .set({ chatRoomId }) .where("id", "=", teamId) .execute(); } return { - chatCode, + chatRoomId, name: team.name, memberUserIds: members.map((m) => m.userId), }; diff --git a/app/features/tournament-lfg/actions/to.$id.looking.server.ts b/app/features/tournament-lfg/actions/to.$id.looking.server.ts index 83eacf51e..d7ad2a776 100644 --- a/app/features/tournament-lfg/actions/to.$id.looking.server.ts +++ b/app/features/tournament-lfg/actions/to.$id.looking.server.ts @@ -1,5 +1,6 @@ import type { ActionFunctionArgs } from "react-router"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; +import * as EventBus from "~/features/events/core/EventBus.server"; import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; import { notify } from "~/features/notifications/core/notify.server"; import { resolveNotifications } from "~/features/notifications/core/resolve.server"; @@ -16,7 +17,10 @@ import { assertUnreachable } from "~/utils/types"; import * as TournamentLFGRepository from "../TournamentLFGRepository.server"; import { lookingSchema } from "../tournament-lfg-schemas"; import { survivingTeamId } from "../tournament-lfg-utils"; -import { setPickupChatMetadata } from "../tournament-lfg-utils.server"; +import { + pickupChatRoomExpiresAt, + setPickupChatMetadata, +} from "../tournament-lfg-utils.server"; export const action = async ({ request, params }: ActionFunctionArgs) => { const { tournament, tournamentId, user } = await tournamentFromParams( @@ -80,7 +84,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { team.memberUserIds.length < tournament.maxMembersPerTeam, "Team is already at max capacity", ); - const pickup = await TournamentLFGRepository.startLooking(team.id); + const pickup = await TournamentLFGRepository.startLooking({ + teamId: team.id, + chatRoomExpiresAt: pickupChatRoomExpiresAt(tournament.ctx.startsAt), + }); if (pickup) { setPickupChatMetadata({ team: pickup, @@ -194,12 +201,15 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { survivingTeamId: surviving, otherTeamId: otherGroup.id, maxGroupSize: tournament.maxMembersPerTeam, + chatRoomExpiresAt: pickupChatRoomExpiresAt(tournament.ctx.startsAt), }); await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId); - if (mergeResult.removedChatCode) { - ChatSystemMessage.removeRoom(mergeResult.removedChatCode); + if (mergeResult.removedChatRoomId) { + ChatSystemMessage.removeRoom( + EventBus.chatRoomChannel(mergeResult.removedChatRoomId), + ); } if (mergeResult.survivor) { diff --git a/app/features/tournament-lfg/tournament-lfg-utils.server.ts b/app/features/tournament-lfg/tournament-lfg-utils.server.ts index 669f8b1cb..7fd3ed56e 100644 --- a/app/features/tournament-lfg/tournament-lfg-utils.server.ts +++ b/app/features/tournament-lfg/tournament-lfg-utils.server.ts @@ -1,16 +1,22 @@ import { add } from "date-fns"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; +import * as EventBus from "~/features/events/core/EventBus.server"; import { tournamentSubsPage } from "~/utils/urls"; import * as TournamentLFGRepository from "./TournamentLFGRepository.server"; const PICKUP_CHAT_EXPIRES_AFTER_DAYS = 7; +/** When a pickup chat room expires: shortly after the tournament so it lasts through the event. */ +export function pickupChatRoomExpiresAt(tournamentStartTime: Date) { + return add(tournamentStartTime, { days: PICKUP_CHAT_EXPIRES_AFTER_DAYS }); +} + export function setPickupChatMetadata({ team, tournament, }: { team: { - chatCode: string; + chatRoomId: number; name: string; memberUserIds: number[]; }; @@ -22,15 +28,13 @@ export function setPickupChatMetadata({ }; }) { return ChatSystemMessage.setMetadata({ - chatCode: team.chatCode, + chatCode: EventBus.chatRoomChannel(team.chatRoomId), header: team.name, subtitle: tournament.name, url: tournamentSubsPage(tournament.id), imageUrl: tournament.logoUrl ?? undefined, participantUserIds: team.memberUserIds, - expiresAt: add(tournament.startTime, { - days: PICKUP_CHAT_EXPIRES_AFTER_DAYS, - }), + expiresAt: pickupChatRoomExpiresAt(tournament.startTime), }); } diff --git a/app/features/tournament-match/TournamentMatchRepository.server.ts b/app/features/tournament-match/TournamentMatchRepository.server.ts index f33195310..a3733307b 100644 --- a/app/features/tournament-match/TournamentMatchRepository.server.ts +++ b/app/features/tournament-match/TournamentMatchRepository.server.ts @@ -39,7 +39,7 @@ export async function findMatchById(id: number) { "TournamentMatch.opponentOne", "TournamentMatch.opponentTwo", "TournamentMatch.winnerSide", - "TournamentMatch.chatCode", + "TournamentMatch.chatRoomId", "TournamentMatch.startedAt", "Tournament.mapPickingStyle", "TournamentRound.id as roundId", diff --git a/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts b/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts index cad060d75..93e2f0cf5 100644 --- a/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts +++ b/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts @@ -2,6 +2,7 @@ import cachified from "@epic-web/cachified"; import type { LoaderFunctionArgs } from "react-router"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; import { chatAccessible } from "~/features/chat/chat-utils"; +import * as EventBus from "~/features/events/core/EventBus.server"; import * as ScannerIngestRepository from "~/features/scanner-ingest/ScannerIngestRepository.server"; import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; @@ -165,7 +166,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { const status = tournament.matchStatusById(matchId); if ( - match.chatCode && + match.chatRoomId && !matchIsOver && match.opponentOne?.id && match.opponentTwo?.id && @@ -191,7 +192,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { const matchContext = tournament.matchContextNamesById(matchId); ChatSystemMessage.setMetadata({ - chatCode: match.chatCode, + chatCode: EventBus.chatRoomChannel(match.chatRoomId), header: matchContext.roundName ?? `Match #${matchId}`, subtitle: tournament.ctx.name, url: tournamentMatchPage({ tournamentId, matchId }), @@ -216,7 +217,9 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { }); const visibleChatCode = - hasPermsToSeeChat && !chatCodeExpired ? match.chatCode : undefined; + hasPermsToSeeChat && !chatCodeExpired && match.chatRoomId + ? EventBus.chatRoomChannel(match.chatRoomId) + : undefined; const isParticipant = match.players.some((p) => p.id === user?.id); const leagueRoundLocked = isLeagueRoundLocked(tournament, match.roundId); @@ -248,7 +251,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { match: { ...match, status, - chatCode: hasPermsToSeeChat ? match.chatCode : undefined, + chatRoomId: hasPermsToSeeChat ? match.chatRoomId : undefined, }, results, reportedWeapons, diff --git a/app/features/tournament/TournamentTeamRepository.server.ts b/app/features/tournament/TournamentTeamRepository.server.ts index 421c235b7..3431aed7f 100644 --- a/app/features/tournament/TournamentTeamRepository.server.ts +++ b/app/features/tournament/TournamentTeamRepository.server.ts @@ -3,6 +3,7 @@ import { sql } 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 type { MapPool } from "~/features/map-list-generator/core/map-pool"; import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; import { flatZip } from "~/utils/arrays"; @@ -757,6 +758,7 @@ export function join({ }, trx, ); + await deleteTeamChatRoom(previousTeamIdToDelete, trx); await trx .deleteFrom("TournamentTeam") .where("TournamentTeam.id", "=", previousTeamIdToDelete) @@ -811,6 +813,8 @@ export function deleteById(tournamentTeamId: number) { .where("MapPoolMap.tournamentTeamId", "=", tournamentTeamId) .execute(); + await deleteTeamChatRoom(tournamentTeamId, trx); + await trx .deleteFrom("TournamentTeam") .where("TournamentTeam.id", "=", tournamentTeamId) @@ -995,3 +999,16 @@ export async function findRecentlyPlayedMapsByIds({ return flatZip(teamOneMaps, teamTwoMaps); } + +async function deleteTeamChatRoom( + tournamentTeamId: number, + trx: Transaction, +) { + const team = await trx + .selectFrom("TournamentTeam") + .select("TournamentTeam.chatRoomId") + .where("TournamentTeam.id", "=", tournamentTeamId) + .executeTakeFirst(); + + await ChatRepository.deleteRoomsByIds([team?.chatRoomId ?? null], trx); +} diff --git a/app/features/tournament/actions/to.$id.register.server.ts b/app/features/tournament/actions/to.$id.register.server.ts index b43e90374..8e6afb8be 100644 --- a/app/features/tournament/actions/to.$id.register.server.ts +++ b/app/features/tournament/actions/to.$id.register.server.ts @@ -1,5 +1,6 @@ import type { ActionFunction } from "react-router"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; +import * as EventBus from "~/features/events/core/EventBus.server"; import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; import { notify } from "~/features/notifications/core/notify.server"; @@ -363,7 +364,9 @@ export const action: ActionFunction = async ({ request, params }) => { await TournamentTeamRepository.deleteById(ownTeam.id); if (pickupChatTeam) { - ChatSystemMessage.removeRoom(pickupChatTeam.chatCode); + ChatSystemMessage.removeRoom( + EventBus.chatRoomChannel(pickupChatTeam.chatRoomId), + ); } for (const userId of ownTeam.memberUserIds) { diff --git a/app/routines/closeExpiredContinueVotes.ts b/app/routines/closeExpiredContinueVotes.ts index 231d92a65..508e37892 100644 --- a/app/routines/closeExpiredContinueVotes.ts +++ b/app/routines/closeExpiredContinueVotes.ts @@ -1,4 +1,5 @@ import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; +import * as EventBus from "~/features/events/core/EventBus.server"; import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; import { logger } from "../utils/logger"; import { Routine } from "./routine.server"; @@ -6,11 +7,14 @@ import { Routine } from "./routine.server"; export const CloseExpiredContinueVotesRoutine = new Routine({ name: "CloseExpiredContinueVotes", func: async () => { - const { numAffectedGroups, chatCodesToRevalidate } = + const { numAffectedGroups, chatRoomIdsToRevalidate } = await SQGroupRepository.closeExpiredContinueVotes(); - for (const room of new Set(chatCodesToRevalidate)) { - ChatSystemMessage.send({ room, revalidateOnly: true }); + for (const roomId of new Set(chatRoomIdsToRevalidate)) { + ChatSystemMessage.send({ + room: EventBus.chatRoomChannel(roomId), + revalidateOnly: true, + }); } logger.info(`Closed continue votes for ${numAffectedGroups} group(s)`); diff --git a/app/routines/resolveStaleSQMatches.ts b/app/routines/resolveStaleSQMatches.ts index 13ee8d9ed..ef7be529e 100644 --- a/app/routines/resolveStaleSQMatches.ts +++ b/app/routines/resolveStaleSQMatches.ts @@ -1,5 +1,6 @@ import { sub } from "date-fns"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; +import * as EventBus from "~/features/events/core/EventBus.server"; import * as Seasons from "~/features/mmr/core/Seasons"; import { refreshUserSkills } from "~/features/mmr/tiered.server"; import { refreshSendouQInstance } from "~/features/sendouq/core/SendouQ.server"; @@ -30,9 +31,9 @@ export const ResolveStaleSQMatchesRoutine = new Routine({ if (result.status === "CANCELED") canceledCount++; if (result.status === "CONFIRMED") confirmedCount++; - if (staleMatch.chatCode) { + if (staleMatch.chatRoomId) { ChatSystemMessage.send({ - room: staleMatch.chatCode, + room: EventBus.chatRoomChannel(staleMatch.chatRoomId), revalidateOnly: true, }); } diff --git a/migrations/20260822134022-chat-rooms.ts b/migrations/20260822134022-chat-rooms.ts index 24d99795b..d1314ee73 100644 --- a/migrations/20260822134022-chat-rooms.ts +++ b/migrations/20260822134022-chat-rooms.ts @@ -2,8 +2,7 @@ import { type Kysely, sql } from "kysely"; /** * Chat rooms, messages and read indicators move from Redis to SQLite. Owner tables get a - * `chatRoomId` FK; their `chatCode` columns are dropped later on this branch once the app - * code no longer reads them. + * `chatRoomId` FK replacing their `chatCode` columns. */ export async function up(db: Kysely): Promise { await db.transaction().execute(async (trx) => { @@ -87,6 +86,8 @@ export async function up(db: Kysely): Promise { col.references("ChatRoom.id").onDelete("set null"), ) .execute(); + + await trx.schema.alterTable(table).dropColumn("chatCode").execute(); } await trx.schema