Wire owner features to ChatRoom rows and drop chatCode

This commit is contained in:
Kalle
2026-08-22 17:58:15 +03:00
parent 3bfc5192fd
commit f5cc1ce491
40 changed files with 776 additions and 238 deletions

View File

@@ -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),
});
}
},
});

View File

@@ -331,7 +331,6 @@ export interface ChatMessageReadIndicator {
}
export interface Group {
chatCode: string | null;
chatRoomId: number | null;
createdAt: Generated<number>;
id: GeneratedAlways<number>;
@@ -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<number>;
@@ -817,7 +814,6 @@ export interface TournamentTeam {
isLooking: Generated<DBBoolean>;
isPlaceholder: Generated<DBBoolean>;
lfgNote: string | null;
chatCode: Generated<string | null>;
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<AssociationVisibility>;
/** 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;

View File

@@ -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({

View File

@@ -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)

View File

@@ -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<DB>,
) {
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<number | null>,
trx?: Transaction<DB>,
) {
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"

View File

@@ -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;

View File

@@ -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);
});
});

View File

@@ -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<typeof findMany> & { chatCode?: string },
row: Unwrapped<typeof findMany> & { 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<ScrimPost | null> {
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();
}
});
}

View File

@@ -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<Awaited<ReturnType<typeof ScrimPostRepository.findById>>>,
) {
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<typeof requireUser>;
}) {
if (!post.chatCode) return;
if (!post.chatRoomId) return;
ChatSystemMessage.send({
room: post.chatCode,
room: EventBus.chatRoomChannel(post.chatRoomId),
type,
context: { name: user.username },
});

View File

@@ -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),
),

View File

@@ -122,7 +122,7 @@ describe("applyFilters", () => {
canceled: null,
createdAt: databaseTimestampNow(),
visibility: null,
chatCode: null,
chatRoomId: null,
text: "",
maps: null,
isScheduledForFuture: false,

View File

@@ -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,

View File

@@ -27,7 +27,7 @@ export interface ScrimPost {
} | null;
team: ScrimPostTeam | null;
users: Array<ScrimPostUser>;
chatCode: string | null;
chatRoomId: number | null;
requests: Array<ScrimPostRequest>;
/** Is the post visible to the user because of their association membership? */
isPrivate?: boolean;

View File

@@ -95,6 +95,19 @@ const playOutMatch = async (setup: Awaited<ReturnType<typeof setupMatch>>) => {
};
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);

View File

@@ -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<DB>) {
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 }) =>

View File

@@ -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 },
});

View File

@@ -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];

View File

@@ -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);
});
});

View File

@@ -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<DB>,
): Promise<string | null> {
): Promise<number | null> {
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. */

View File

@@ -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,
});
}

View File

@@ -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,
});
}

View File

@@ -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,
});
}

View File

@@ -113,7 +113,7 @@ function createOwnGroup(
noScreen: false,
modePreferences: [],
teamMapModePreferences: undefined,
chatCode: null,
chatRoomId: null,
status: "ACTIVE",
matchId: null,
inviteCode: "test123",

View File

@@ -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<T extends (typeof this.groups)[number]>(
group: T,
): Omit<T, "inviteCode" | "chatCode" | "members"> & {
): Omit<T, "inviteCode" | "chatRoomId" | "members"> & {
members: T["members"] | undefined;
} {
const {
inviteCode: _inviteCode,
chatCode: _chatCode,
chatRoomId: _chatRoomId,
members,
...baseGroup
} = group;

View File

@@ -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),
});
}

View File

@@ -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,
};
};

View File

@@ -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),

View File

@@ -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) {

View File

@@ -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<number, number>();
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<number | null> = [];
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<void> {
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<DB>) {
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.

View File

@@ -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(),
);

View File

@@ -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<PickupChatTeam | null> {
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<DB>,
): Promise<PickupChatTeam | null> {
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),
};

View File

@@ -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) {

View File

@@ -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),
});
}

View File

@@ -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",

View File

@@ -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,

View File

@@ -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<DB>,
) {
const team = await trx
.selectFrom("TournamentTeam")
.select("TournamentTeam.chatRoomId")
.where("TournamentTeam.id", "=", tournamentTeamId)
.executeTakeFirst();
await ChatRepository.deleteRoomsByIds([team?.chatRoomId ?? null], trx);
}

View File

@@ -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) {

View File

@@ -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)`);

View File

@@ -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,
});
}

View File

@@ -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<any>): Promise<void> {
await db.transaction().execute(async (trx) => {
@@ -87,6 +86,8 @@ export async function up(db: Kysely<any>): Promise<void> {
col.references("ChatRoom.id").onDelete("set null"),
)
.execute();
await trx.schema.alterTable(table).dropColumn("chatCode").execute();
}
await trx.schema