diff --git a/app/features/chat/ChatRoomResolver.server.test.ts b/app/features/chat/ChatRoomResolver.server.test.ts new file mode 100644 index 000000000..7d596f010 --- /dev/null +++ b/app/features/chat/ChatRoomResolver.server.test.ts @@ -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 => ({ + 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!; +}; diff --git a/app/features/chat/ChatRoomResolver.server.ts b/app/features/chat/ChatRoomResolver.server.ts new file mode 100644 index 000000000..d554e7cb9 --- /dev/null +++ b/app/features/chat/ChatRoomResolver.server.ts @@ -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; + 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 { + 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 { + 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`${sql.ref(`TournamentMatch.${column}`)} ->> '$.id'`; +} + +async function resolveSqGroupRooms( + rooms: ChatRoomRow[], +): Promise { + 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 { + 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 { + 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 { + 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 { + 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( + 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> { + const result = new Map(); + 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; +} diff --git a/app/features/events/core/TopicAccess.server.test.ts b/app/features/events/core/TopicAccess.server.test.ts index 4b73bafe0..32a708142 100644 --- a/app/features/events/core/TopicAccess.server.test.ts +++ b/app/features/events/core/TopicAccess.server.test.ts @@ -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); + }); }); }); diff --git a/app/features/events/core/TopicAccess.server.ts b/app/features/events/core/TopicAccess.server.ts index 54f3de04a..311622a27 100644 --- a/app/features/events/core/TopicAccess.server.ts +++ b/app/features/events/core/TopicAccess.server.ts @@ -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 { 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; diff --git a/app/features/events/routes/sse.$connectionId.topics.ts b/app/features/events/routes/sse.$connectionId.topics.ts index d1f1b7e44..0aced8365 100644 --- a/app/features/events/routes/sse.$connectionId.topics.ts +++ b/app/features/events/routes/sse.$connectionId.topics.ts @@ -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 }); } } diff --git a/migrations/20260822134022-chat-rooms.ts b/migrations/20260822134022-chat-rooms.ts index d1314ee73..69e48cc91 100644 --- a/migrations/20260822134022-chat-rooms.ts +++ b/migrations/20260822134022-chat-rooms.ts @@ -95,6 +95,7 @@ export async function up(db: Kysely): Promise { .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): Promise { .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): Promise { .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): Promise { .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): Promise { .on("ScrimPost") .column("chatRoomId") .unique() + .where(sql.ref("chatRoomId"), "is not", null) .execute(); }); } diff --git a/scripts/benchmark-db/cases.ts b/scripts/benchmark-db/cases.ts index ae9c1afce..9ddd4f80f 100644 --- a/scripts/benchmark-db/cases.ts +++ b/scripts/benchmark-db/cases.ts @@ -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),