mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-07 19:55:46 -05:00
Wire owner features to ChatRoom rows and drop chatCode
This commit is contained in:
@@ -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),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -122,7 +122,7 @@ describe("applyFilters", () => {
|
||||
canceled: null,
|
||||
createdAt: databaseTimestampNow(),
|
||||
visibility: null,
|
||||
chatCode: null,
|
||||
chatRoomId: null,
|
||||
text: "",
|
||||
maps: null,
|
||||
isScheduledForFuture: false,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ function createOwnGroup(
|
||||
noScreen: false,
|
||||
modePreferences: [],
|
||||
teamMapModePreferences: undefined,
|
||||
chatCode: null,
|
||||
chatRoomId: null,
|
||||
status: "ACTIVE",
|
||||
matchId: null,
|
||||
inviteCode: "test123",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)`);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user