mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-07 11:46:09 -05:00
Add ChatRoomResolver and wire chat-room topic authorization
This commit is contained in:
394
app/features/chat/ChatRoomResolver.server.test.ts
Normal file
394
app/features/chat/ChatRoomResolver.server.test.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
import { addHours } from "date-fns";
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import * as ScrimPostFactory from "~/db/seed/factories/ScrimPostFactory";
|
||||
import * as SQGroupFactory from "~/db/seed/factories/SQGroupFactory";
|
||||
import * as SQMatchFactory from "~/db/seed/factories/SQMatchFactory";
|
||||
import * as TeamFactory from "~/db/seed/factories/TeamFactory";
|
||||
import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
|
||||
import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import { db } from "~/db/sql";
|
||||
import * as ScrimPostRepository from "~/features/scrims/ScrimPostRepository.server";
|
||||
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import * as ChatRepository from "./ChatRepository.server";
|
||||
import * as ChatRoomResolver from "./ChatRoomResolver.server";
|
||||
|
||||
const users = UserFactory.pool();
|
||||
|
||||
// ADMIN_ID is 1 under NODE_ENV=test, so the first pool user is site staff
|
||||
const adminId = () => users.id(1);
|
||||
const outsiderId = () => users.id(11);
|
||||
|
||||
beforeEach(async () => {
|
||||
await users.create(12);
|
||||
});
|
||||
|
||||
const setupSqMatch = async () => {
|
||||
const alphaUserIds = [users.id(2), users.id(3), users.id(4), users.id(5)];
|
||||
const bravoUserIds = [users.id(6), users.id(7), users.id(8), users.id(9)];
|
||||
|
||||
const match = await SQMatchFactory.create({ alphaUserIds, bravoUserIds });
|
||||
|
||||
return { match, alphaUserIds, bravoUserIds };
|
||||
};
|
||||
|
||||
const setupStartedTournamentMatch = async () => {
|
||||
const authorId = users.id(2);
|
||||
const teamAlphaUserIds = [users.id(3), users.id(4), users.id(5), users.id(6)];
|
||||
const teamBravoUserIds = [
|
||||
users.id(7),
|
||||
users.id(8),
|
||||
users.id(9),
|
||||
users.id(10),
|
||||
];
|
||||
|
||||
const tournament = await TournamentFactory.create({ authorId });
|
||||
for (const memberUserIds of [teamAlphaUserIds, teamBravoUserIds]) {
|
||||
await TournamentTeamFactory.create(
|
||||
{ tournamentId: tournament.id, memberUserIds },
|
||||
{ isCheckedIn: true },
|
||||
);
|
||||
}
|
||||
await TournamentFactory.startBracket(tournament.id);
|
||||
|
||||
const match = await db
|
||||
.selectFrom("TournamentMatch")
|
||||
.innerJoin(
|
||||
"TournamentStage",
|
||||
"TournamentStage.id",
|
||||
"TournamentMatch.stageId",
|
||||
)
|
||||
.select(["TournamentMatch.id", "TournamentMatch.chatRoomId"])
|
||||
.where("TournamentStage.tournamentId", "=", tournament.id)
|
||||
.where("TournamentMatch.chatRoomId", "is not", null)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return {
|
||||
tournament,
|
||||
authorId,
|
||||
matchId: match.id,
|
||||
chatRoomId: match.chatRoomId!,
|
||||
teamAlphaUserIds,
|
||||
teamBravoUserIds,
|
||||
};
|
||||
};
|
||||
|
||||
const setupAcceptedScrim = async () => {
|
||||
const postUserIds = [users.id(2), users.id(3)];
|
||||
const requestUserIds = [users.id(4), users.id(5)];
|
||||
const startsAt = addHours(new Date(), 10);
|
||||
|
||||
const { id: postId } = await ScrimPostFactory.create({
|
||||
startsAt: dateToDatabaseTimestamp(startsAt),
|
||||
users: postUserIds.map((userId, i) => ({
|
||||
userId,
|
||||
isOwner: i === 0 ? (1 as const) : (0 as const),
|
||||
})),
|
||||
});
|
||||
const team = await TeamFactory.create({ memberUserIds: requestUserIds });
|
||||
const requestId = await ScrimPostRepository.insertRequest({
|
||||
scrimPostId: postId,
|
||||
teamId: team.id,
|
||||
message: null,
|
||||
startsAt: null,
|
||||
users: requestUserIds.map((userId, i) => ({
|
||||
userId,
|
||||
isOwner: i === 0 ? (1 as const) : (0 as const),
|
||||
})),
|
||||
});
|
||||
await ScrimPostRepository.acceptRequest(requestId);
|
||||
|
||||
const post = await ScrimPostRepository.findById(postId);
|
||||
|
||||
return {
|
||||
postId,
|
||||
chatRoomId: post!.chatRoomId!,
|
||||
postUserIds,
|
||||
requestUserIds,
|
||||
startsAt,
|
||||
};
|
||||
};
|
||||
|
||||
describe("ChatRoomResolver.resolve", () => {
|
||||
test("resolves an SQ_GROUP room to the group's live members", async () => {
|
||||
const memberUserIds = [users.id(2), users.id(3)];
|
||||
const group = await SQGroupFactory.create({ memberUserIds });
|
||||
|
||||
const [room] = await ChatRoomResolver.resolve([
|
||||
await groupChatRoomId(group.id),
|
||||
]);
|
||||
|
||||
expect(room.type).toBe("SQ_GROUP");
|
||||
expect(room.participantUserIds.sort()).toEqual(memberUserIds.sort());
|
||||
expect(room.url).toBe("/q/looking");
|
||||
expect(room.observerUserIds).toEqual([]);
|
||||
});
|
||||
|
||||
test("resolves an SQ_MATCH room to both groups' members", async () => {
|
||||
const { match, alphaUserIds, bravoUserIds } = await setupSqMatch();
|
||||
|
||||
const [room] = await ChatRoomResolver.resolve([match.chatRoomId!]);
|
||||
|
||||
expect(room.type).toBe("SQ_MATCH");
|
||||
expect(room.participantUserIds.sort()).toEqual(
|
||||
[...alphaUserIds, ...bravoUserIds].sort(),
|
||||
);
|
||||
expect(room.titleParams).toEqual({ matchId: String(match.id) });
|
||||
expect(room.url).toContain(String(match.id));
|
||||
});
|
||||
|
||||
test("resolves a TOURNAMENT_MATCH room to both teams' members with organizer observers", async () => {
|
||||
const {
|
||||
chatRoomId,
|
||||
matchId,
|
||||
authorId,
|
||||
teamAlphaUserIds,
|
||||
teamBravoUserIds,
|
||||
} = await setupStartedTournamentMatch();
|
||||
|
||||
const [room] = await ChatRoomResolver.resolve([chatRoomId]);
|
||||
|
||||
expect(room.type).toBe("TOURNAMENT_MATCH");
|
||||
expect(room.participantUserIds.sort()).toEqual(
|
||||
[...teamAlphaUserIds, ...teamBravoUserIds].sort(),
|
||||
);
|
||||
expect(room.titleParams.matchId).toBe(String(matchId));
|
||||
expect(room.titleParams.tournamentName).toEqual(expect.any(String));
|
||||
expect(room.observerUserIds).toContain(authorId);
|
||||
});
|
||||
|
||||
test("TOURNAMENT_MATCH observers include tournament staff organizers and streamers", async () => {
|
||||
const { tournament, chatRoomId } = await setupStartedTournamentMatch();
|
||||
await TournamentRepository.setStaff({
|
||||
tournamentId: tournament.id,
|
||||
staff: [{ userId: users.id(12), role: "STREAMER" }],
|
||||
});
|
||||
|
||||
const [room] = await ChatRoomResolver.resolve([chatRoomId]);
|
||||
|
||||
expect(room.observerUserIds).toContain(users.id(12));
|
||||
});
|
||||
|
||||
test("resolves a TOURNAMENT_TEAM room to the pickup team's members", async () => {
|
||||
const authorId = users.id(2);
|
||||
const memberUserIds = [users.id(3), users.id(4)];
|
||||
const tournament = await TournamentFactory.create({ authorId });
|
||||
const team = await TournamentTeamFactory.create(
|
||||
{ tournamentId: tournament.id, memberUserIds },
|
||||
{ isLooking: true },
|
||||
);
|
||||
|
||||
const [room] = await ChatRoomResolver.resolve([
|
||||
await teamChatRoomId(team.id),
|
||||
]);
|
||||
|
||||
expect(room.type).toBe("TOURNAMENT_TEAM");
|
||||
expect(room.participantUserIds.sort()).toEqual(memberUserIds.sort());
|
||||
expect(room.titleParams.teamName).toEqual(expect.any(String));
|
||||
expect(room.observerUserIds).toContain(authorId);
|
||||
});
|
||||
|
||||
test("resolves a SCRIM room to the post's users plus the accepted request's users", async () => {
|
||||
const { chatRoomId, postUserIds, requestUserIds, startsAt } =
|
||||
await setupAcceptedScrim();
|
||||
|
||||
const [room] = await ChatRoomResolver.resolve([chatRoomId]);
|
||||
|
||||
expect(room.type).toBe("SCRIM");
|
||||
expect(room.participantUserIds.sort()).toEqual(
|
||||
[...postUserIds, ...requestUserIds].sort(),
|
||||
);
|
||||
expect(room.titleParams.startsAt).toBe(
|
||||
String(dateToDatabaseTimestamp(startsAt)),
|
||||
);
|
||||
});
|
||||
|
||||
test("resolves to nothing when the owner row is gone", async () => {
|
||||
const { postId, chatRoomId } = await setupAcceptedScrim();
|
||||
// deleting an owner in a way that skips its delete transaction would orphan
|
||||
// the room; simulate by resolving an id the reaper would clean up
|
||||
await ScrimPostRepository.deleteById(postId);
|
||||
|
||||
expect(await ChatRoomResolver.resolve([chatRoomId])).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns an empty array for no room ids", async () => {
|
||||
expect(await ChatRoomResolver.resolve([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatRoomResolver.findAllByUserId", () => {
|
||||
test("returns the member's own group and match rooms of an SQ match", async () => {
|
||||
const { match, alphaUserIds } = await setupSqMatch();
|
||||
|
||||
const rooms = await ChatRoomResolver.findAllByUserId(alphaUserIds[0]);
|
||||
|
||||
expect(rooms.map((room) => [room.roomId, room.type]).sort()).toEqual(
|
||||
[
|
||||
[match.chatRoomId!, "SQ_MATCH"],
|
||||
[await groupChatRoomId(match.alphaGroup.id), "SQ_GROUP"],
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
test("leaves out a solo group's room", async () => {
|
||||
await SQGroupFactory.create({ memberUserIds: [users.id(2)] });
|
||||
|
||||
expect(await ChatRoomResolver.findAllByUserId(users.id(2))).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns tournament match and team rooms through membership", async () => {
|
||||
const { chatRoomId, teamAlphaUserIds } =
|
||||
await setupStartedTournamentMatch();
|
||||
|
||||
const rooms = await ChatRoomResolver.findAllByUserId(teamAlphaUserIds[0]);
|
||||
|
||||
expect(rooms.map((room) => room.roomId)).toContain(chatRoomId);
|
||||
});
|
||||
|
||||
test("returns scrim rooms for both sides", async () => {
|
||||
const { chatRoomId, requestUserIds } = await setupAcceptedScrim();
|
||||
|
||||
const rooms = await ChatRoomResolver.findAllByUserId(requestUserIds[1]);
|
||||
|
||||
expect(rooms.map((room) => room.roomId)).toEqual([chatRoomId]);
|
||||
});
|
||||
|
||||
test("returns nothing for a non-participant", async () => {
|
||||
await setupSqMatch();
|
||||
await setupAcceptedScrim();
|
||||
|
||||
expect(await ChatRoomResolver.findAllByUserId(outsiderId())).toEqual([]);
|
||||
});
|
||||
|
||||
test("leaves out closed rooms", async () => {
|
||||
const { requestUserIds } = await setupAcceptedScrim();
|
||||
await ChatRepository.closeExpiredRooms(addHours(new Date(), 100_000));
|
||||
|
||||
expect(await ChatRoomResolver.findAllByUserId(requestUserIds[0])).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatRoomResolver.canObserve", () => {
|
||||
test("site staff can observe both group chats of an SQ match", async () => {
|
||||
const { match } = await setupSqMatch();
|
||||
|
||||
const rooms = await ChatRoomResolver.resolve([
|
||||
match.chatRoomId!,
|
||||
await groupChatRoomId(match.alphaGroup.id),
|
||||
await groupChatRoomId(match.bravoGroup.id),
|
||||
]);
|
||||
|
||||
expect(rooms).toHaveLength(3);
|
||||
for (const room of rooms) {
|
||||
expect(ChatRoomResolver.canObserve(room, adminId())).toBe(true);
|
||||
expect(ChatRoomResolver.canObserve(room, outsiderId())).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("a participant of one group cannot view the other group's chat", async () => {
|
||||
const { match, alphaUserIds } = await setupSqMatch();
|
||||
|
||||
const [bravoRoom] = await ChatRoomResolver.resolve([
|
||||
await groupChatRoomId(match.bravoGroup.id),
|
||||
]);
|
||||
|
||||
expect(ChatRoomResolver.canView(bravoRoom, alphaUserIds[0])).toBe(false);
|
||||
});
|
||||
|
||||
test("tournament organizer observes but does not post", async () => {
|
||||
const { chatRoomId, authorId } = await setupStartedTournamentMatch();
|
||||
|
||||
const [room] = await ChatRoomResolver.resolve([chatRoomId]);
|
||||
|
||||
expect(ChatRoomResolver.canObserve(room, authorId)).toBe(true);
|
||||
expect(ChatRoomResolver.canView(room, authorId)).toBe(true);
|
||||
expect(ChatRoomResolver.canPost(room, authorId)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatRoomResolver.canView", () => {
|
||||
test("participants lose view once the room closes; observers keep it", async () => {
|
||||
const { chatRoomId, authorId, teamAlphaUserIds } =
|
||||
await setupStartedTournamentMatch();
|
||||
await ChatRepository.closeExpiredRooms(addHours(new Date(), 100_000));
|
||||
|
||||
const [room] = await ChatRoomResolver.resolve([chatRoomId]);
|
||||
|
||||
expect(room.closedAt).not.toBeNull();
|
||||
expect(ChatRoomResolver.canView(room, teamAlphaUserIds[0])).toBe(false);
|
||||
expect(ChatRoomResolver.canView(room, authorId)).toBe(true);
|
||||
expect(ChatRoomResolver.canView(room, adminId())).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatRoomResolver.canPost", () => {
|
||||
const baseRoom = (
|
||||
overrides: Partial<ChatRoomResolver.ResolvedRoom>,
|
||||
): ChatRoomResolver.ResolvedRoom => ({
|
||||
roomId: 1,
|
||||
type: "SQ_MATCH",
|
||||
titleParams: {},
|
||||
url: "/",
|
||||
imageUrl: null,
|
||||
participantUserIds: [100],
|
||||
observerUserIds: [],
|
||||
expiresAt: dateToDatabaseTimestamp(addHours(new Date(), 1)),
|
||||
closedAt: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
why: "participant of an open room",
|
||||
room: baseRoom({}),
|
||||
userId: 100,
|
||||
allowed: true,
|
||||
},
|
||||
{
|
||||
why: "non-participant",
|
||||
room: baseRoom({}),
|
||||
userId: 101,
|
||||
allowed: false,
|
||||
},
|
||||
{
|
||||
why: "expired room",
|
||||
room: baseRoom({
|
||||
expiresAt: dateToDatabaseTimestamp(addHours(new Date(), -1)),
|
||||
}),
|
||||
userId: 100,
|
||||
allowed: false,
|
||||
},
|
||||
{
|
||||
why: "closed room",
|
||||
room: baseRoom({ closedAt: 1 }),
|
||||
userId: 100,
|
||||
allowed: false,
|
||||
},
|
||||
])("$why -> $allowed", ({ room, userId, allowed }) => {
|
||||
expect(ChatRoomResolver.canPost(room, userId)).toBe(allowed);
|
||||
});
|
||||
});
|
||||
|
||||
const groupChatRoomId = async (groupId: number) => {
|
||||
const group = await db
|
||||
.selectFrom("Group")
|
||||
.select("Group.chatRoomId")
|
||||
.where("Group.id", "=", groupId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return group.chatRoomId!;
|
||||
};
|
||||
|
||||
const teamChatRoomId = async (teamId: number) => {
|
||||
const team = await db
|
||||
.selectFrom("TournamentTeam")
|
||||
.select("TournamentTeam.chatRoomId")
|
||||
.where("TournamentTeam.id", "=", teamId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return team.chatRoomId!;
|
||||
};
|
||||
638
app/features/chat/ChatRoomResolver.server.ts
Normal file
638
app/features/chat/ChatRoomResolver.server.ts
Normal file
@@ -0,0 +1,638 @@
|
||||
import { sql } from "kysely";
|
||||
import { db } from "~/db/sql";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import { isAdmin, isStaff } from "~/modules/permissions/utils";
|
||||
import { databaseTimestampNow } from "~/utils/dates";
|
||||
import {
|
||||
jsonArrayFrom,
|
||||
tournamentLogoWithDefault,
|
||||
} from "~/utils/kysely.server";
|
||||
import {
|
||||
SENDOUQ_LOOKING_PAGE,
|
||||
scrimPage,
|
||||
sendouQMatchPage,
|
||||
tournamentMatchPage,
|
||||
tournamentSubsPage,
|
||||
} from "~/utils/urls";
|
||||
import type { ChatRoomType } from "./chat-types";
|
||||
|
||||
export interface ResolvedRoom {
|
||||
roomId: number;
|
||||
type: ChatRoomType;
|
||||
/** Interpolation values for the client-localized room title, keyed per room type. */
|
||||
titleParams: Record<string, string>;
|
||||
url: string;
|
||||
imageUrl: string | null;
|
||||
participantUserIds: number[];
|
||||
/**
|
||||
* Read-only observers resolved from the owning entity (tournament organizers and
|
||||
* streamers). Site ADMIN/STAFF observe through the role axis instead, see
|
||||
* {@link canObserve}.
|
||||
*/
|
||||
observerUserIds: number[];
|
||||
expiresAt: number;
|
||||
closedAt: number | null;
|
||||
}
|
||||
|
||||
type ChatRoomRow = Tables["ChatRoom"];
|
||||
|
||||
/**
|
||||
* Resolves rooms' participants, titles and access live from their owning entities.
|
||||
* Rooms whose owner row is gone resolve to nothing.
|
||||
*/
|
||||
export async function resolve(roomIds: number[]): Promise<ResolvedRoom[]> {
|
||||
if (roomIds.length === 0) return [];
|
||||
|
||||
const rooms = await db
|
||||
.selectFrom("ChatRoom")
|
||||
.selectAll()
|
||||
.where("ChatRoom.id", "in", roomIds)
|
||||
.execute();
|
||||
|
||||
const byType = (type: ChatRoomType) =>
|
||||
rooms.filter((room) => room.type === type);
|
||||
|
||||
const resolved = (
|
||||
await Promise.all([
|
||||
resolveSqGroupRooms(byType("SQ_GROUP")),
|
||||
resolveSqMatchRooms(byType("SQ_MATCH")),
|
||||
resolveTournamentMatchRooms(byType("TOURNAMENT_MATCH")),
|
||||
resolveTournamentTeamRooms(byType("TOURNAMENT_TEAM")),
|
||||
resolveScrimRooms(byType("SCRIM")),
|
||||
])
|
||||
).flat();
|
||||
|
||||
return resolved.sort((a, b) => a.roomId - b.roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rooms the user currently participates in (unexpired and unclosed). Room-first per
|
||||
* the resolver spike: drives from the open room set and probes membership through
|
||||
* the owner tables' indexes, never searching on the JSON opponent ids.
|
||||
*/
|
||||
export async function findAllByUserId(userId: number): Promise<ResolvedRoom[]> {
|
||||
const now = databaseTimestampNow();
|
||||
|
||||
const openRooms = () =>
|
||||
db
|
||||
.selectFrom("ChatRoom")
|
||||
.select("ChatRoom.id")
|
||||
.where("ChatRoom.expiresAt", ">", now)
|
||||
.where("ChatRoom.closedAt", "is", null);
|
||||
|
||||
const [groupRooms, matchRooms, tournamentMatchRooms, teamRooms, scrimRooms] =
|
||||
await Promise.all([
|
||||
openRooms()
|
||||
.innerJoin("Group", "Group.chatRoomId", "ChatRoom.id")
|
||||
.where(({ exists, selectFrom }) =>
|
||||
exists(
|
||||
selectFrom("GroupMember")
|
||||
.select("GroupMember.userId")
|
||||
.whereRef("GroupMember.groupId", "=", "Group.id")
|
||||
.where("GroupMember.userId", "=", userId),
|
||||
),
|
||||
)
|
||||
.execute(),
|
||||
openRooms()
|
||||
.innerJoin("GroupMatch", "GroupMatch.chatRoomId", "ChatRoom.id")
|
||||
.where(({ exists, selectFrom }) =>
|
||||
exists(
|
||||
selectFrom("GroupMember")
|
||||
.select("GroupMember.userId")
|
||||
.where("GroupMember.userId", "=", userId)
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb(
|
||||
"GroupMember.groupId",
|
||||
"=",
|
||||
eb.ref("GroupMatch.alphaGroupId"),
|
||||
),
|
||||
eb(
|
||||
"GroupMember.groupId",
|
||||
"=",
|
||||
eb.ref("GroupMatch.bravoGroupId"),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
)
|
||||
.execute(),
|
||||
openRooms()
|
||||
.innerJoin(
|
||||
"TournamentMatch",
|
||||
"TournamentMatch.chatRoomId",
|
||||
"ChatRoom.id",
|
||||
)
|
||||
.where(({ exists, selectFrom }) =>
|
||||
exists(
|
||||
selectFrom("TournamentTeamMember")
|
||||
.select("TournamentTeamMember.userId")
|
||||
.where("TournamentTeamMember.userId", "=", userId)
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb(
|
||||
"TournamentTeamMember.tournamentTeamId",
|
||||
"=",
|
||||
opponentTeamId("opponentOne"),
|
||||
),
|
||||
eb(
|
||||
"TournamentTeamMember.tournamentTeamId",
|
||||
"=",
|
||||
opponentTeamId("opponentTwo"),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
)
|
||||
.execute(),
|
||||
openRooms()
|
||||
.innerJoin("TournamentTeam", "TournamentTeam.chatRoomId", "ChatRoom.id")
|
||||
.where(({ exists, selectFrom }) =>
|
||||
exists(
|
||||
selectFrom("TournamentTeamMember")
|
||||
.select("TournamentTeamMember.userId")
|
||||
.whereRef(
|
||||
"TournamentTeamMember.tournamentTeamId",
|
||||
"=",
|
||||
"TournamentTeam.id",
|
||||
)
|
||||
.where("TournamentTeamMember.userId", "=", userId),
|
||||
),
|
||||
)
|
||||
.execute(),
|
||||
openRooms()
|
||||
.innerJoin("ScrimPost", "ScrimPost.chatRoomId", "ChatRoom.id")
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom("ScrimPostUser")
|
||||
.select("ScrimPostUser.userId")
|
||||
.whereRef("ScrimPostUser.scrimPostId", "=", "ScrimPost.id")
|
||||
.where("ScrimPostUser.userId", "=", userId),
|
||||
),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom("ScrimPostRequestUser")
|
||||
.innerJoin(
|
||||
"ScrimPostRequest",
|
||||
"ScrimPostRequest.id",
|
||||
"ScrimPostRequestUser.scrimPostRequestId",
|
||||
)
|
||||
.select("ScrimPostRequestUser.userId")
|
||||
.whereRef("ScrimPostRequest.scrimPostId", "=", "ScrimPost.id")
|
||||
.where("ScrimPostRequest.isAccepted", "=", 1)
|
||||
.where("ScrimPostRequestUser.userId", "=", userId),
|
||||
),
|
||||
]),
|
||||
)
|
||||
.execute(),
|
||||
]);
|
||||
|
||||
const resolved = await resolve(
|
||||
[
|
||||
...groupRooms,
|
||||
...matchRooms,
|
||||
...tournamentMatchRooms,
|
||||
...teamRooms,
|
||||
...scrimRooms,
|
||||
].map((room) => room.id),
|
||||
);
|
||||
|
||||
// a solo group has no conversation to show yet
|
||||
return resolved.filter(
|
||||
(room) => room.type !== "SQ_GROUP" || room.participantUserIds.length >= 2,
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether the user has read-only observer access to the room: site ADMIN/STAFF, or an observer resolved from the owning entity. */
|
||||
export function canObserve(room: ResolvedRoom, userId: number): boolean {
|
||||
return (
|
||||
isAdmin({ id: userId }) ||
|
||||
isStaff({ id: userId }) ||
|
||||
room.observerUserIds.includes(userId)
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether the user may read the room. After `closedAt` only observers retain access. */
|
||||
export function canView(room: ResolvedRoom, userId: number): boolean {
|
||||
if (room.closedAt !== null) return canObserve(room, userId);
|
||||
|
||||
return room.participantUserIds.includes(userId) || canObserve(room, userId);
|
||||
}
|
||||
|
||||
/** Whether the user may post to the room: participants only, while the room is unexpired and unclosed. */
|
||||
export function canPost(room: ResolvedRoom, userId: number): boolean {
|
||||
if (room.closedAt !== null) return false;
|
||||
if (room.expiresAt <= databaseTimestampNow()) return false;
|
||||
|
||||
return room.participantUserIds.includes(userId);
|
||||
}
|
||||
|
||||
function opponentTeamId(column: "opponentOne" | "opponentTwo") {
|
||||
return sql<number>`${sql.ref(`TournamentMatch.${column}`)} ->> '$.id'`;
|
||||
}
|
||||
|
||||
async function resolveSqGroupRooms(
|
||||
rooms: ChatRoomRow[],
|
||||
): Promise<ResolvedRoom[]> {
|
||||
if (rooms.length === 0) return [];
|
||||
|
||||
const owners = await db
|
||||
.selectFrom("Group")
|
||||
.select((eb) => [
|
||||
"Group.chatRoomId",
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("GroupMember")
|
||||
.select("GroupMember.userId")
|
||||
.whereRef("GroupMember.groupId", "=", "Group.id"),
|
||||
).as("members"),
|
||||
])
|
||||
.where(
|
||||
"Group.chatRoomId",
|
||||
"in",
|
||||
rooms.map((room) => room.id),
|
||||
)
|
||||
.execute();
|
||||
|
||||
return joinOwners(rooms, owners, (owner) => ({
|
||||
titleParams: {},
|
||||
url: SENDOUQ_LOOKING_PAGE,
|
||||
imageUrl: null,
|
||||
participantUserIds: owner.members.map((member) => member.userId),
|
||||
observerUserIds: [],
|
||||
}));
|
||||
}
|
||||
|
||||
async function resolveSqMatchRooms(
|
||||
rooms: ChatRoomRow[],
|
||||
): Promise<ResolvedRoom[]> {
|
||||
if (rooms.length === 0) return [];
|
||||
|
||||
const owners = await db
|
||||
.selectFrom("GroupMatch")
|
||||
.select((eb) => [
|
||||
"GroupMatch.id",
|
||||
"GroupMatch.chatRoomId",
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("GroupMember")
|
||||
.select("GroupMember.userId")
|
||||
.where((inner) =>
|
||||
inner.or([
|
||||
inner(
|
||||
"GroupMember.groupId",
|
||||
"=",
|
||||
inner.ref("GroupMatch.alphaGroupId"),
|
||||
),
|
||||
inner(
|
||||
"GroupMember.groupId",
|
||||
"=",
|
||||
inner.ref("GroupMatch.bravoGroupId"),
|
||||
),
|
||||
]),
|
||||
),
|
||||
).as("members"),
|
||||
])
|
||||
.where(
|
||||
"GroupMatch.chatRoomId",
|
||||
"in",
|
||||
rooms.map((room) => room.id),
|
||||
)
|
||||
.execute();
|
||||
|
||||
return joinOwners(rooms, owners, (owner) => ({
|
||||
titleParams: { matchId: String(owner.id) },
|
||||
url: sendouQMatchPage(owner.id),
|
||||
imageUrl: null,
|
||||
participantUserIds: owner.members.map((member) => member.userId),
|
||||
observerUserIds: [],
|
||||
}));
|
||||
}
|
||||
|
||||
async function resolveTournamentMatchRooms(
|
||||
rooms: ChatRoomRow[],
|
||||
): Promise<ResolvedRoom[]> {
|
||||
if (rooms.length === 0) return [];
|
||||
|
||||
const owners = await db
|
||||
.selectFrom("TournamentMatch")
|
||||
.innerJoin(
|
||||
"TournamentStage",
|
||||
"TournamentStage.id",
|
||||
"TournamentMatch.stageId",
|
||||
)
|
||||
.innerJoin(
|
||||
"CalendarEvent",
|
||||
"CalendarEvent.tournamentId",
|
||||
"TournamentStage.tournamentId",
|
||||
)
|
||||
.select((eb) => [
|
||||
"TournamentMatch.id",
|
||||
"TournamentMatch.chatRoomId",
|
||||
"TournamentMatch.opponentOne",
|
||||
"TournamentMatch.opponentTwo",
|
||||
"TournamentStage.tournamentId",
|
||||
"CalendarEvent.name as tournamentName",
|
||||
tournamentLogoWithDefault(eb).as("logoUrl"),
|
||||
])
|
||||
.where(
|
||||
"TournamentMatch.chatRoomId",
|
||||
"in",
|
||||
rooms.map((room) => room.id),
|
||||
)
|
||||
.execute();
|
||||
|
||||
// the opponent team ids come from the already-fetched match rows; they are
|
||||
// never used as search predicates (see the resolver SQL spike)
|
||||
const teamIds = [
|
||||
...new Set(
|
||||
owners.flatMap((owner) =>
|
||||
[owner.opponentOne?.id, owner.opponentTwo?.id].filter(
|
||||
(id): id is number => typeof id === "number",
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
const members =
|
||||
teamIds.length > 0
|
||||
? await db
|
||||
.selectFrom("TournamentTeamMember")
|
||||
.select([
|
||||
"TournamentTeamMember.tournamentTeamId",
|
||||
"TournamentTeamMember.userId",
|
||||
])
|
||||
.where("TournamentTeamMember.tournamentTeamId", "in", teamIds)
|
||||
.execute()
|
||||
: [];
|
||||
|
||||
const observers = await observersByTournamentId([
|
||||
...new Set(owners.map((owner) => owner.tournamentId)),
|
||||
]);
|
||||
|
||||
return joinOwners(rooms, owners, (owner) => {
|
||||
const opponentTeamIds = [
|
||||
owner.opponentOne?.id,
|
||||
owner.opponentTwo?.id,
|
||||
].filter((id): id is number => typeof id === "number");
|
||||
const tournamentObservers = observers.get(owner.tournamentId);
|
||||
|
||||
return {
|
||||
titleParams: {
|
||||
tournamentName: owner.tournamentName,
|
||||
matchId: String(owner.id),
|
||||
},
|
||||
url: tournamentMatchPage({
|
||||
tournamentId: owner.tournamentId,
|
||||
matchId: owner.id,
|
||||
}),
|
||||
imageUrl: owner.logoUrl,
|
||||
participantUserIds: members
|
||||
.filter((member) => opponentTeamIds.includes(member.tournamentTeamId))
|
||||
.map((member) => member.userId),
|
||||
observerUserIds: [
|
||||
...(tournamentObservers?.organizerIds ?? []),
|
||||
...(tournamentObservers?.streamerIds ?? []),
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveTournamentTeamRooms(
|
||||
rooms: ChatRoomRow[],
|
||||
): Promise<ResolvedRoom[]> {
|
||||
if (rooms.length === 0) return [];
|
||||
|
||||
const owners = await db
|
||||
.selectFrom("TournamentTeam")
|
||||
.innerJoin(
|
||||
"CalendarEvent",
|
||||
"CalendarEvent.tournamentId",
|
||||
"TournamentTeam.tournamentId",
|
||||
)
|
||||
.select((eb) => [
|
||||
"TournamentTeam.chatRoomId",
|
||||
"TournamentTeam.name",
|
||||
"TournamentTeam.tournamentId",
|
||||
"CalendarEvent.name as tournamentName",
|
||||
tournamentLogoWithDefault(eb).as("logoUrl"),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("TournamentTeamMember")
|
||||
.select("TournamentTeamMember.userId")
|
||||
.whereRef(
|
||||
"TournamentTeamMember.tournamentTeamId",
|
||||
"=",
|
||||
"TournamentTeam.id",
|
||||
),
|
||||
).as("members"),
|
||||
])
|
||||
.where(
|
||||
"TournamentTeam.chatRoomId",
|
||||
"in",
|
||||
rooms.map((room) => room.id),
|
||||
)
|
||||
.execute();
|
||||
|
||||
const observers = await observersByTournamentId([
|
||||
...new Set(owners.map((owner) => owner.tournamentId)),
|
||||
]);
|
||||
|
||||
return joinOwners(rooms, owners, (owner) => ({
|
||||
titleParams: {
|
||||
teamName: owner.name,
|
||||
tournamentName: owner.tournamentName,
|
||||
},
|
||||
url: tournamentSubsPage(owner.tournamentId),
|
||||
imageUrl: owner.logoUrl,
|
||||
participantUserIds: owner.members.map((member) => member.userId),
|
||||
observerUserIds: observers.get(owner.tournamentId)?.organizerIds ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
async function resolveScrimRooms(
|
||||
rooms: ChatRoomRow[],
|
||||
): Promise<ResolvedRoom[]> {
|
||||
if (rooms.length === 0) return [];
|
||||
|
||||
const owners = await db
|
||||
.selectFrom("ScrimPost")
|
||||
.select((eb) => [
|
||||
"ScrimPost.id",
|
||||
"ScrimPost.chatRoomId",
|
||||
"ScrimPost.startsAt",
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("ScrimPostUser")
|
||||
.select("ScrimPostUser.userId")
|
||||
.whereRef("ScrimPostUser.scrimPostId", "=", "ScrimPost.id"),
|
||||
).as("postUsers"),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("ScrimPostRequestUser")
|
||||
.innerJoin(
|
||||
"ScrimPostRequest",
|
||||
"ScrimPostRequest.id",
|
||||
"ScrimPostRequestUser.scrimPostRequestId",
|
||||
)
|
||||
.select("ScrimPostRequestUser.userId")
|
||||
.whereRef("ScrimPostRequest.scrimPostId", "=", "ScrimPost.id")
|
||||
.where("ScrimPostRequest.isAccepted", "=", 1),
|
||||
).as("acceptedRequestUsers"),
|
||||
eb
|
||||
.selectFrom("ScrimPostRequest")
|
||||
.select("ScrimPostRequest.startsAt")
|
||||
.whereRef("ScrimPostRequest.scrimPostId", "=", "ScrimPost.id")
|
||||
.where("ScrimPostRequest.isAccepted", "=", 1)
|
||||
.limit(1)
|
||||
.$asScalar()
|
||||
.as("acceptedRequestStartsAt"),
|
||||
])
|
||||
.where(
|
||||
"ScrimPost.chatRoomId",
|
||||
"in",
|
||||
rooms.map((room) => room.id),
|
||||
)
|
||||
.execute();
|
||||
|
||||
return joinOwners(rooms, owners, (owner) => ({
|
||||
titleParams: {
|
||||
startsAt: String(owner.acceptedRequestStartsAt ?? owner.startsAt),
|
||||
},
|
||||
url: scrimPage(owner.id),
|
||||
imageUrl: null,
|
||||
participantUserIds: [
|
||||
...owner.postUsers.map((user) => user.userId),
|
||||
...owner.acceptedRequestUsers.map((user) => user.userId),
|
||||
],
|
||||
observerUserIds: [],
|
||||
}));
|
||||
}
|
||||
|
||||
function joinOwners<T extends { chatRoomId: number | null }>(
|
||||
rooms: ChatRoomRow[],
|
||||
owners: T[],
|
||||
build: (
|
||||
owner: T,
|
||||
) => Pick<
|
||||
ResolvedRoom,
|
||||
| "titleParams"
|
||||
| "url"
|
||||
| "imageUrl"
|
||||
| "participantUserIds"
|
||||
| "observerUserIds"
|
||||
>,
|
||||
): ResolvedRoom[] {
|
||||
const ownerByRoomId = new Map(
|
||||
owners.map((owner) => [owner.chatRoomId, owner]),
|
||||
);
|
||||
|
||||
return rooms.flatMap((room) => {
|
||||
const owner = ownerByRoomId.get(room.id);
|
||||
if (!owner) return [];
|
||||
|
||||
return {
|
||||
roomId: room.id,
|
||||
type: room.type,
|
||||
expiresAt: room.expiresAt,
|
||||
closedAt: room.closedAt,
|
||||
...build(owner),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
type TournamentObservers = {
|
||||
organizerIds: number[];
|
||||
streamerIds: number[];
|
||||
};
|
||||
|
||||
async function observersByTournamentId(
|
||||
tournamentIds: number[],
|
||||
): Promise<Map<number, TournamentObservers>> {
|
||||
const result = new Map<number, TournamentObservers>();
|
||||
if (tournamentIds.length === 0) return result;
|
||||
|
||||
const observersOf = (tournamentId: number) => {
|
||||
let observers = result.get(tournamentId);
|
||||
if (!observers) {
|
||||
observers = { organizerIds: [], streamerIds: [] };
|
||||
result.set(tournamentId, observers);
|
||||
}
|
||||
return observers;
|
||||
};
|
||||
|
||||
const events = await db
|
||||
.selectFrom("CalendarEvent")
|
||||
.select([
|
||||
"CalendarEvent.tournamentId",
|
||||
"CalendarEvent.authorId",
|
||||
"CalendarEvent.organizationId",
|
||||
])
|
||||
.where("CalendarEvent.tournamentId", "in", tournamentIds)
|
||||
.execute();
|
||||
const staff = await db
|
||||
.selectFrom("TournamentStaff")
|
||||
.select([
|
||||
"TournamentStaff.tournamentId",
|
||||
"TournamentStaff.userId",
|
||||
"TournamentStaff.role",
|
||||
])
|
||||
.where("TournamentStaff.tournamentId", "in", tournamentIds)
|
||||
.execute();
|
||||
|
||||
const organizationIds = [
|
||||
...new Set(
|
||||
events
|
||||
.map((event) => event.organizationId)
|
||||
.filter((id): id is number => id !== null),
|
||||
),
|
||||
];
|
||||
const organizationMembers =
|
||||
organizationIds.length > 0
|
||||
? await db
|
||||
.selectFrom("TournamentOrganizationMember")
|
||||
.select([
|
||||
"TournamentOrganizationMember.organizationId",
|
||||
"TournamentOrganizationMember.userId",
|
||||
"TournamentOrganizationMember.role",
|
||||
])
|
||||
.where(
|
||||
"TournamentOrganizationMember.organizationId",
|
||||
"in",
|
||||
organizationIds,
|
||||
)
|
||||
.where("TournamentOrganizationMember.role", "in", [
|
||||
"ADMIN",
|
||||
"ORGANIZER",
|
||||
"STREAMER",
|
||||
])
|
||||
.execute()
|
||||
: [];
|
||||
|
||||
for (const event of events) {
|
||||
if (event.tournamentId === null) continue;
|
||||
const observers = observersOf(event.tournamentId);
|
||||
observers.organizerIds.push(event.authorId);
|
||||
|
||||
for (const member of organizationMembers) {
|
||||
if (member.organizationId !== event.organizationId) continue;
|
||||
if (member.role === "STREAMER") {
|
||||
observers.streamerIds.push(member.userId);
|
||||
} else {
|
||||
observers.organizerIds.push(member.userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const staffMember of staff) {
|
||||
const observers = observersOf(staffMember.tournamentId);
|
||||
if (staffMember.role === "STREAMER") {
|
||||
observers.streamerIds.push(staffMember.userId);
|
||||
} else {
|
||||
observers.organizerIds.push(staffMember.userId);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,6 +1,16 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import * as SQGroupFactory from "~/db/seed/factories/SQGroupFactory";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import { db } from "~/db/sql";
|
||||
import * as TopicAccess from "./TopicAccess.server";
|
||||
|
||||
const users = UserFactory.pool();
|
||||
|
||||
// ADMIN_ID is 1 under NODE_ENV=test, so the first pool user is site staff
|
||||
const adminId = () => users.id(1);
|
||||
const memberId = () => users.id(2);
|
||||
const outsiderId = () => users.id(4);
|
||||
|
||||
describe("TopicAccess.canSubscribe", () => {
|
||||
test.each([
|
||||
{ topic: "sq-looking", allowed: true },
|
||||
@@ -8,9 +18,47 @@ describe("TopicAccess.canSubscribe", () => {
|
||||
{ topic: "match__123", allowed: true },
|
||||
{ topic: "sq-group__7", allowed: true },
|
||||
{ topic: "user__5", allowed: false },
|
||||
{ topic: "chat-room__5", allowed: false },
|
||||
{ topic: "chat-room__abc", allowed: false },
|
||||
{ topic: "chat-room__999", allowed: false },
|
||||
{ topic: "unknown-topic", allowed: false },
|
||||
])("$topic -> $allowed", ({ topic, allowed }) => {
|
||||
expect(TopicAccess.canSubscribe(1, topic)).toBe(allowed);
|
||||
])("$topic -> $allowed", async ({ topic, allowed }) => {
|
||||
expect(await TopicAccess.canSubscribe(1, topic)).toBe(allowed);
|
||||
});
|
||||
|
||||
describe("chat room topics", () => {
|
||||
beforeEach(async () => {
|
||||
await users.create(4);
|
||||
});
|
||||
|
||||
const setupGroupRoom = async () => {
|
||||
const group = await SQGroupFactory.create({
|
||||
memberUserIds: [memberId(), users.id(3)],
|
||||
});
|
||||
const { chatRoomId } = await db
|
||||
.selectFrom("Group")
|
||||
.select("Group.chatRoomId")
|
||||
.where("Group.id", "=", group.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return `chat-room__${chatRoomId}`;
|
||||
};
|
||||
|
||||
test("participants may subscribe to their room", async () => {
|
||||
const topic = await setupGroupRoom();
|
||||
|
||||
expect(await TopicAccess.canSubscribe(memberId(), topic)).toBe(true);
|
||||
});
|
||||
|
||||
test("site staff may subscribe as observers", async () => {
|
||||
const topic = await setupGroupRoom();
|
||||
|
||||
expect(await TopicAccess.canSubscribe(adminId(), topic)).toBe(true);
|
||||
});
|
||||
|
||||
test("other users may not subscribe", async () => {
|
||||
const topic = await setupGroupRoom();
|
||||
|
||||
expect(await TopicAccess.canSubscribe(outsiderId(), topic)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
import * as ChatRoomResolver from "~/features/chat/ChatRoomResolver.server";
|
||||
|
||||
const PUBLIC_TOPICS = new Set(["sq-looking"]);
|
||||
const PUBLIC_TOPIC_PREFIXES = ["tournament__", "match__", "sq-group__"];
|
||||
const CHAT_ROOM_TOPIC_PREFIX = "chat-room__";
|
||||
|
||||
/** Whether the user may subscribe their SSE connection to the topic. The `user__` channel is never client-controllable. */
|
||||
export function canSubscribe(_userId: number, topic: string): boolean {
|
||||
export async function canSubscribe(
|
||||
userId: number,
|
||||
topic: string,
|
||||
): Promise<boolean> {
|
||||
if (PUBLIC_TOPICS.has(topic)) return true;
|
||||
if (PUBLIC_TOPIC_PREFIXES.some((prefix) => topic.startsWith(prefix))) {
|
||||
return true;
|
||||
}
|
||||
if (topic.startsWith(CHAT_ROOM_TOPIC_PREFIX)) {
|
||||
// TODO: observer check via ChatRoomResolver once owner wiring lands
|
||||
return false;
|
||||
const roomId = Number(topic.slice(CHAT_ROOM_TOPIC_PREFIX.length));
|
||||
if (!Number.isInteger(roomId) || roomId <= 0) return false;
|
||||
|
||||
const [room] = await ChatRoomResolver.resolve([roomId]);
|
||||
if (!room) return false;
|
||||
|
||||
return ChatRoomResolver.canView(room, userId);
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
@@ -24,7 +24,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const data = await parseRequestPayload({ request, schema: topicsSchema });
|
||||
|
||||
for (const topic of data.topics) {
|
||||
if (!TopicAccess.canSubscribe(user.id, topic)) {
|
||||
if (!(await TopicAccess.canSubscribe(user.id, topic))) {
|
||||
throw new Response(null, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ export async function up(db: Kysely<any>): Promise<void> {
|
||||
.on("Group")
|
||||
.column("chatRoomId")
|
||||
.unique()
|
||||
.where(sql.ref("chatRoomId"), "is not", null)
|
||||
.execute();
|
||||
|
||||
await trx.schema
|
||||
@@ -102,6 +103,7 @@ export async function up(db: Kysely<any>): Promise<void> {
|
||||
.on("GroupMatch")
|
||||
.column("chatRoomId")
|
||||
.unique()
|
||||
.where(sql.ref("chatRoomId"), "is not", null)
|
||||
.execute();
|
||||
|
||||
await trx.schema
|
||||
@@ -109,6 +111,7 @@ export async function up(db: Kysely<any>): Promise<void> {
|
||||
.on("TournamentMatch")
|
||||
.column("chatRoomId")
|
||||
.unique()
|
||||
.where(sql.ref("chatRoomId"), "is not", null)
|
||||
.execute();
|
||||
|
||||
await trx.schema
|
||||
@@ -116,6 +119,7 @@ export async function up(db: Kysely<any>): Promise<void> {
|
||||
.on("TournamentTeam")
|
||||
.column("chatRoomId")
|
||||
.unique()
|
||||
.where(sql.ref("chatRoomId"), "is not", null)
|
||||
.execute();
|
||||
|
||||
await trx.schema
|
||||
@@ -123,6 +127,7 @@ export async function up(db: Kysely<any>): Promise<void> {
|
||||
.on("ScrimPost")
|
||||
.column("chatRoomId")
|
||||
.unique()
|
||||
.where(sql.ref("chatRoomId"), "is not", null)
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as BadgeRepository from "~/features/badges/BadgeRepository.server";
|
||||
import * as BuildRepository from "~/features/builds/BuildRepository.server";
|
||||
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
|
||||
import * as ChatRepository from "~/features/chat/ChatRepository.server";
|
||||
import * as ChatRoomResolver from "~/features/chat/ChatRoomResolver.server";
|
||||
import * as FriendRepository from "~/features/friends/FriendRepository.server";
|
||||
import * as ImageRepository from "~/features/img-upload/ImageRepository.server";
|
||||
import * as LeaderboardRepository from "~/features/leaderboards/LeaderboardRepository.server";
|
||||
@@ -238,6 +239,14 @@ export function buildCases(fx: Fixtures): {
|
||||
ChatRepository.findAllMessagesByRoomId(roomId),
|
||||
);
|
||||
|
||||
// ChatRoomResolver
|
||||
add("ChatRoomResolver.resolve", fx.heavyChatRoomId, (roomId) =>
|
||||
ChatRoomResolver.resolve([roomId]),
|
||||
);
|
||||
add("ChatRoomResolver.findAllByUserId", fx.heavyUser, (user) =>
|
||||
ChatRoomResolver.findAllByUserId(user.id),
|
||||
);
|
||||
|
||||
// FriendRepository
|
||||
add("FriendRepository.findByUserIdWithActivity", fx.heavyFriendPair, (pair) =>
|
||||
FriendRepository.findByUserIdWithActivity(pair.userId),
|
||||
|
||||
Reference in New Issue
Block a user