Add ChatRoomResolver and wire chat-room topic authorization

This commit is contained in:
Kalle
2026-08-22 18:06:30 +03:00
parent f5cc1ce491
commit c62210bc57
7 changed files with 1112 additions and 8 deletions

View 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!;
};

View 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;
}

View File

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

View File

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

View File

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

View File

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

View File

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