From 3969f5e4eb470c47f7501eb59de77decf37e2f44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oleksandr=20Kapitu=C5=82a?= Date: Mon, 5 Oct 2026 12:02:28 +0200 Subject: [PATCH 1/5] feat(chat): invite players directly into a private room Room owners, and moderators when the room allows it, can search players by username, check invite statuses, and send an invite that expires after seven days. Invitees list, accept or decline their invites; accepting joins the room. Adds the chat_room_invite table, an invite notification, audit records and a chat:member-joined realtime signal. --- .../src/audit/__tests__/map-event.test.ts | 38 + packages/core/src/audit/plugin.ts | 32 + .../core/src/contracts/adapters/rate-limit.ts | 2 + packages/core/src/contracts/schemas/events.ts | 25 + .../chat/__tests__/chat.service.int.test.ts | 3 + .../src/engagement/chat/contract/constants.ts | 25 + .../src/engagement/chat/contract/index.ts | 95 + .../migrations/0017_chat_room_invite.sql | 15 + .../migrations/meta/0017_snapshot.json | 1727 +++++++++++++++++ .../drizzle/migrations/meta/_journal.json | 7 + packages/core/src/engagement/chat/plugin.ts | 7 + .../core/src/engagement/chat/router/index.ts | 115 ++ .../core/src/engagement/chat/schema/index.ts | 25 + .../chat/service/chat-room-invite.service.ts | 561 ++++++ .../service/chat-room-membership.service.ts | 102 +- .../service/errors/chat-room-invite.errors.ts | 44 + .../notifications/contract/index.ts | 1 + .../src/engagement/notifications/plugin.ts | 8 + .../__tests__/chat-room-invites.e2e.test.ts | 697 +++++++ 19 files changed, 3508 insertions(+), 21 deletions(-) create mode 100644 packages/core/src/engagement/chat/drizzle/migrations/0017_chat_room_invite.sql create mode 100644 packages/core/src/engagement/chat/drizzle/migrations/meta/0017_snapshot.json create mode 100644 packages/core/src/engagement/chat/service/chat-room-invite.service.ts create mode 100644 packages/core/src/engagement/chat/service/errors/chat-room-invite.errors.ts create mode 100644 packages/testing/src/__tests__/chat-room-invites.e2e.test.ts diff --git a/packages/core/src/audit/__tests__/map-event.test.ts b/packages/core/src/audit/__tests__/map-event.test.ts index bff7891dc..cdda8c1d3 100644 --- a/packages/core/src/audit/__tests__/map-event.test.ts +++ b/packages/core/src/audit/__tests__/map-event.test.ts @@ -1007,3 +1007,41 @@ describe('mapEventToRecord: chat.room.member.joined', () => { expect(row).toMatchObject({ actorType: 'player', actorId: playerId }); }); }); + +describe('mapEventToRecord: chat.room.invite', () => { + const roomId = '99999999-9999-4999-8999-999999999999'; + const inviteId = '88888888-8888-4888-8888-888888888888'; + const inviterId = '77777777-7777-4777-8777-777777777777'; + + it('records a declined invite against the invitee', async () => { + const row = await mapEventToRecord('chat.room.invite.declined', { + inviteId, + roomId, + inviterId, + inviteeId: userId, + playerId, + }); + + expect(row).toMatchObject({ + actorType: 'player', + actorId: playerId, + resourceType: 'chat_room_invite', + resourceId: inviteId, + after: { roomId, inviterId, status: 'declined' }, + }); + }); + + it('falls back to the inviter user id when no player backs the inviter', async () => { + const row = await mapEventToRecord('chat.room.invite.sent', { + inviteId, + roomId, + inviterId, + inviteeId: userId, + inviterUsername: 'host', + roomName: 'room', + playerId: null, + }); + + expect(row).toMatchObject({ actorType: 'player', actorId: inviterId, resourceId: inviteId }); + }); +}); diff --git a/packages/core/src/audit/plugin.ts b/packages/core/src/audit/plugin.ts index 91e2e5cee..a140a1027 100644 --- a/packages/core/src/audit/plugin.ts +++ b/packages/core/src/audit/plugin.ts @@ -470,6 +470,35 @@ export async function mapEventToRecord( }; } + // actorId = the acting player's resolved playerId (inviter on sent, invitee on accepted and + // declined), falling back to their raw user id; resource = the invite row. A decline is the + // invitee's own choice, so it records as a success despite the topic suffix. + if (topic === 'chat.room.invite.sent') { + return { + ...base, + actorType: 'player', + actorId: str(p['playerId']) ?? str(p['inviterId']), + resourceType: 'chat_room_invite', + resourceId: str(p['inviteId']), + after: { roomId: str(p['roomId']), inviteeId: str(p['inviteeId']), status: 'pending' }, + }; + } + + if (topic === 'chat.room.invite.accepted' || topic === 'chat.room.invite.declined') { + return { + ...base, + actorType: 'player', + actorId: str(p['playerId']) ?? str(p['inviteeId']), + resourceType: 'chat_room_invite', + resourceId: str(p['inviteId']), + after: { + roomId: str(p['roomId']), + inviterId: str(p['inviterId']), + status: topic === 'chat.room.invite.accepted' ? 'accepted' : 'declined', + }, + }; + } + // actorId = the granting/revoking owner's resolved playerId, falling back to the raw acting // user id when no player record backs them (the actor must never be lost on a permission // change). resource = the member whose role moved; before/after carry the two roles. @@ -1331,6 +1360,9 @@ const SUBSCRIBED_TOPICS: DomainEventName[] = [ 'chat.room.deleted', 'chat.room.member.joined', 'chat.room.member.left', + 'chat.room.invite.sent', + 'chat.room.invite.accepted', + 'chat.room.invite.declined', 'chat.room.member.kicked', 'chat.room.member.banned', // chat.gift.sent diff --git a/packages/core/src/contracts/adapters/rate-limit.ts b/packages/core/src/contracts/adapters/rate-limit.ts index ac9312603..a334fd7c5 100644 --- a/packages/core/src/contracts/adapters/rate-limit.ts +++ b/packages/core/src/contracts/adapters/rate-limit.ts @@ -27,6 +27,8 @@ export const RATE_LIMIT_KEYS = { WALLET_MUTATION: 'wallet-mutation', WALLET_WEBHOOK: 'wallet-webhook', CHAT_ROOM_JOIN: 'chat-room-join', + CHAT_ROOM_INVITE: 'chat-room-invite', + CHAT_ROOM_INVITE_LOOKUP: 'chat-room-invite-lookup', CHAT_SEND: 'chat-send', REPORT_ACCESS_DENIED: 'report-access-denied', GEO_CHECK_IP: 'geo-check-ip', diff --git a/packages/core/src/contracts/schemas/events.ts b/packages/core/src/contracts/schemas/events.ts index cc8f3a7c4..8f1772c7d 100644 --- a/packages/core/src/contracts/schemas/events.ts +++ b/packages/core/src/contracts/schemas/events.ts @@ -861,6 +861,31 @@ export const domainEventSchemas = { playerId: UuidSchema.nullable(), adminId: UuidSchema.optional(), }), + // A room owner or moderator invited a player into a private room. `playerId` is the + // inviter's; the accept and decline payloads carry the invitee's. + 'chat.room.invite.sent': authContextBase.extend({ + inviteId: UuidSchema, + roomId: UuidSchema, + inviterId: UuidSchema, + inviteeId: UuidSchema, + inviterUsername: z.string(), + roomName: z.string(), + playerId: UuidSchema.nullable(), + }), + 'chat.room.invite.accepted': authContextBase.extend({ + inviteId: UuidSchema, + roomId: UuidSchema, + inviterId: UuidSchema, + inviteeId: UuidSchema, + playerId: UuidSchema.nullable(), + }), + 'chat.room.invite.declined': authContextBase.extend({ + inviteId: UuidSchema, + roomId: UuidSchema, + inviterId: UuidSchema, + inviteeId: UuidSchema, + playerId: UuidSchema.nullable(), + }), 'chat.room.member.left': authContextBase.extend({ roomId: UuidSchema, userId: UuidSchema, diff --git a/packages/core/src/engagement/chat/__tests__/chat.service.int.test.ts b/packages/core/src/engagement/chat/__tests__/chat.service.int.test.ts index 6f0c94d90..99db0190e 100644 --- a/packages/core/src/engagement/chat/__tests__/chat.service.int.test.ts +++ b/packages/core/src/engagement/chat/__tests__/chat.service.int.test.ts @@ -2432,6 +2432,7 @@ describe('ChatService.setMemberRole (real PG)', () => { it('signals the room channel on both promotion and revoke', async () => { const { transport, signal } = transportWithSignal(); const { svc, room, ownerId, memberId } = await roomWithMember(transport); + signal.mockClear(); await svc.setMemberRole({ actorId: ownerId, @@ -2465,6 +2466,7 @@ describe('ChatService.setMemberRole (real PG)', () => { it('stays silent when the member already holds the requested role', async () => { const { transport, signal } = transportWithSignal(); const { svc, room, ownerId, memberId } = await roomWithMember(transport); + signal.mockClear(); await svc.setMemberRole({ actorId: ownerId, @@ -2482,6 +2484,7 @@ describe('ChatService.setMemberRole (real PG)', () => { const { svc, room, memberId } = await roomWithMember(transport); const otherId = randomUUID(); await svc.joinRoom({ userId: otherId, joinCode: room.joinCode!, ...NO_CLIENT_META }); + signal.mockClear(); await expect( svc.setMemberRole({ diff --git a/packages/core/src/engagement/chat/contract/constants.ts b/packages/core/src/engagement/chat/contract/constants.ts index 0297377c5..a35433171 100644 --- a/packages/core/src/engagement/chat/contract/constants.ts +++ b/packages/core/src/engagement/chat/contract/constants.ts @@ -24,6 +24,31 @@ export const CHAT_ROOM_ASSIGNABLE_ROLES = ['member', 'moderator'] as const; // Named alongside the transport's own ACCESS_REVOKED_SIGNAL; a client subscribes to it by name. export const CHAT_MEMBER_ROLE_CHANGED_SIGNAL = 'chat:member-role-changed'; +export const CHAT_MEMBER_JOINED_SIGNAL = 'chat:member-joined'; + export const CHAT_ROOM_SCHEDULED_FOR_DELETION_SIGNAL = 'chat:room-scheduled-for-deletion'; export const OWNERLESS_ROOM_RETENTION_DAYS = 30; + +export const CHAT_ROOM_INVITE_STATUSES = ['pending', 'accepted', 'declined'] as const; + +export const CHAT_ROOM_INVITE_CANDIDATE_STATUSES = [ + 'available', + 'member', + 'invited', + 'banned', +] as const; + +// `unavailable` hides why a player cannot be invited, a block by them included. +export const CHAT_ROOM_INVITE_LOOKUP_STATUSES = [ + ...CHAT_ROOM_INVITE_CANDIDATE_STATUSES, + 'unavailable', +] as const; + +export const ROOM_INVITE_EXPIRY_DAYS = 7; + +export const ROOM_INVITE_STATUS_LOOKUP_MAX = 100; + +export const ROOM_INVITE_CANDIDATE_LIMIT_MAX = 50; + +export const ROOM_INVITE_SEARCH_MAX_LENGTH = 50; diff --git a/packages/core/src/engagement/chat/contract/index.ts b/packages/core/src/engagement/chat/contract/index.ts index 603083a60..7e6b6f1dc 100644 --- a/packages/core/src/engagement/chat/contract/index.ts +++ b/packages/core/src/engagement/chat/contract/index.ts @@ -21,6 +21,12 @@ import { JOIN_CODE_INPUT_MAX_LENGTH, CHAT_ROOM_ROLES, CHAT_ROOM_ASSIGNABLE_ROLES, + CHAT_ROOM_INVITE_STATUSES, + CHAT_ROOM_INVITE_CANDIDATE_STATUSES, + CHAT_ROOM_INVITE_LOOKUP_STATUSES, + ROOM_INVITE_STATUS_LOOKUP_MAX, + ROOM_INVITE_CANDIDATE_LIMIT_MAX, + ROOM_INVITE_SEARCH_MAX_LENGTH, } from './constants.js'; export * from './constants.js'; @@ -100,6 +106,9 @@ export const ChatMemberRoleChangedSignalSchema = z.object({ }); export type ChatMemberRoleChangedSignal = z.infer; +export const ChatMemberJoinedSignalSchema = z.object({ roomId: UuidSchema, userId: UuidSchema }); +export type ChatMemberJoinedSignal = z.infer; + export const ChatRoomScheduledForDeletionSignalSchema = z.object({ roomId: UuidSchema, scheduledDeletionAt: TimestampSchema.nullable(), @@ -321,6 +330,63 @@ export const SendGlobalMessageInputSchema = z }) .refine(hasContentOrAttachment, { message: 'A message needs content or an attachment' }); +export const ChatRoomInviteStatusSchema = z.enum(CHAT_ROOM_INVITE_STATUSES); +export type ChatRoomInviteStatus = z.infer; + +export const ChatRoomInviteCandidateStatusSchema = z.enum(CHAT_ROOM_INVITE_CANDIDATE_STATUSES); +export type ChatRoomInviteCandidateStatus = z.infer; + +export const ChatRoomInviteLookupStatusSchema = z.enum(CHAT_ROOM_INVITE_LOOKUP_STATUSES); +export type ChatRoomInviteLookupStatus = z.infer; + +export const ChatRoomInviteSchema = z.object({ + id: UuidSchema, + roomId: UuidSchema, + inviterId: UuidSchema, + inviteeId: UuidSchema, + status: ChatRoomInviteStatusSchema, + createdAt: TimestampSchema, + respondedAt: TimestampSchema.nullable(), +}); +export type ChatRoomInvite = z.infer; + +export const ChatRoomInviteCandidateSchema = z.object({ + userId: UuidSchema, + username: z.string(), + avatarUrl: z.string().nullable(), + inviteStatus: ChatRoomInviteCandidateStatusSchema, +}); +export type ChatRoomInviteCandidate = z.infer; + +export const ChatRoomInviteLookupSchema = z.object({ + userId: UuidSchema, + inviteStatus: ChatRoomInviteLookupStatusSchema, +}); +export type ChatRoomInviteLookup = z.infer; + +export const MyChatRoomInviteSchema = z.object({ + id: UuidSchema, + roomId: UuidSchema, + roomName: z.string(), + inviterId: UuidSchema, + inviterUsername: z.string().nullable(), + createdAt: TimestampSchema, +}); +export type MyChatRoomInvite = z.infer; + +export const SearchRoomInviteCandidatesInputSchema = z.object({ + roomId: UuidSchema, + q: z.string().trim().min(1).max(ROOM_INVITE_SEARCH_MAX_LENGTH), + limit: z.coerce.number().int().min(1).max(ROOM_INVITE_CANDIDATE_LIMIT_MAX).default(20), +}); + +export const GetRoomInviteStatusesInputSchema = z.object({ + roomId: UuidSchema, + userIds: z.array(UuidSchema).min(1).max(ROOM_INVITE_STATUS_LOOKUP_MAX), +}); + +const RoomInviteIdInput = z.object({ inviteId: UuidSchema }); + export const chatContract = { listRooms: oc.route({ method: 'GET', path: '/chat/rooms' }).output(z.array(ChatRoomSchema)), @@ -466,6 +532,35 @@ export const chatContract = { .input(RoomIdInput) .output(z.object({ success: z.literal(true) })), + inviteToRoom: oc + .route({ method: 'POST', path: '/chat/rooms/{roomId}/invites' }) + .input(RoomUserInput) + .output(ChatRoomInviteSchema), + + searchRoomInviteCandidates: oc + .route({ method: 'GET', path: '/chat/rooms/{roomId}/invite-candidates' }) + .input(SearchRoomInviteCandidatesInputSchema) + .output(z.array(ChatRoomInviteCandidateSchema)), + + getRoomInviteStatuses: oc + .route({ method: 'POST', path: '/chat/rooms/{roomId}/invite-statuses' }) + .input(GetRoomInviteStatusesInputSchema) + .output(z.array(ChatRoomInviteLookupSchema)), + + listMyRoomInvites: oc + .route({ method: 'GET', path: '/chat/invites' }) + .output(z.array(MyChatRoomInviteSchema)), + + acceptRoomInvite: oc + .route({ method: 'POST', path: '/chat/invites/{inviteId}/accept' }) + .input(RoomInviteIdInput) + .output(ChatRoomSchema), + + declineRoomInvite: oc + .route({ method: 'POST', path: '/chat/invites/{inviteId}/decline' }) + .input(RoomInviteIdInput) + .output(z.object({ success: z.literal(true) })), + getRoom: oc .route({ method: 'GET', path: '/chat/rooms/{roomId}' }) .input(RoomIdInput) diff --git a/packages/core/src/engagement/chat/drizzle/migrations/0017_chat_room_invite.sql b/packages/core/src/engagement/chat/drizzle/migrations/0017_chat_room_invite.sql new file mode 100644 index 000000000..729bed125 --- /dev/null +++ b/packages/core/src/engagement/chat/drizzle/migrations/0017_chat_room_invite.sql @@ -0,0 +1,15 @@ +CREATE TYPE "public"."chat_room_invite_status" AS ENUM('pending', 'accepted', 'declined');--> statement-breakpoint +CREATE TABLE "chat_room_invite" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "room_id" uuid NOT NULL, + "inviter_id" uuid NOT NULL, + "invitee_id" uuid NOT NULL, + "status" "chat_room_invite_status" DEFAULT 'pending' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "responded_at" timestamp with time zone +); +--> statement-breakpoint +ALTER TABLE "chat_room_invite" ADD CONSTRAINT "chat_room_invite_room_id_chat_room_id_fk" FOREIGN KEY ("room_id") REFERENCES "public"."chat_room"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "chat_room_invite_pending_room_invitee_key" ON "chat_room_invite" USING btree ("room_id","invitee_id") WHERE "chat_room_invite"."status" = 'pending';--> statement-breakpoint +CREATE INDEX "chat_room_invite_invitee_status_idx" ON "chat_room_invite" USING btree ("invitee_id","status");--> statement-breakpoint +CREATE INDEX "chat_room_invite_room_idx" ON "chat_room_invite" USING btree ("room_id"); \ No newline at end of file diff --git a/packages/core/src/engagement/chat/drizzle/migrations/meta/0017_snapshot.json b/packages/core/src/engagement/chat/drizzle/migrations/meta/0017_snapshot.json new file mode 100644 index 000000000..8fbfbb321 --- /dev/null +++ b/packages/core/src/engagement/chat/drizzle/migrations/meta/0017_snapshot.json @@ -0,0 +1,1727 @@ +{ + "id": "c8f83bfe-3a79-4711-acbb-da9300d91b1f", + "prevId": "550e8414-4ccc-4253-b985-e957c81c4511", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.chat_message": { + "name": "chat_message", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "room_id": { + "name": "room_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "chat_message_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "attachment": { + "name": "attachment", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_deleted": { + "name": "is_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_msg_room_id_created_at_idx": { + "name": "chat_msg_room_id_created_at_idx", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_msg_created_at_idx": { + "name": "chat_msg_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_msg_deleted_at_idx": { + "name": "chat_msg_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_msg_user_id_created_at_idx": { + "name": "chat_msg_user_id_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_message_room_id_chat_room_id_fk": { + "name": "chat_message_room_id_chat_room_id_fk", + "tableFrom": "chat_message", + "tableTo": "chat_room", + "columnsFrom": ["room_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_mute": { + "name": "chat_mute", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "room_id": { + "name": "room_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "chat_moderation_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'__global'" + }, + "muted_by": { + "name": "muted_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lifted_at": { + "name": "lifted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lifted_by": { + "name": "lifted_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "expiry_recorded_at": { + "name": "expiry_recorded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chat_mute_active_scope_key": { + "name": "chat_mute_active_scope_key", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat_mute\".\"lifted_at\" IS NULL AND \"chat_mute\".\"room_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_mute_active_room_key": { + "name": "chat_mute_active_room_key", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat_mute\".\"lifted_at\" IS NULL AND \"chat_mute\".\"room_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_mute_user_room_idx": { + "name": "chat_mute_user_room_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_mute_expires_at_idx": { + "name": "chat_mute_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_platform_ban": { + "name": "chat_platform_ban", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "banned_by": { + "name": "banned_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "chat_moderation_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'__all_public'" + }, + "room_id": { + "name": "room_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lifted_at": { + "name": "lifted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lifted_by": { + "name": "lifted_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "expiry_recorded_at": { + "name": "expiry_recorded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chat_platform_ban_active_scope_key": { + "name": "chat_platform_ban_active_scope_key", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat_platform_ban\".\"lifted_at\" IS NULL AND \"chat_platform_ban\".\"room_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_platform_ban_active_room_key": { + "name": "chat_platform_ban_active_room_key", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat_platform_ban\".\"lifted_at\" IS NULL AND \"chat_platform_ban\".\"room_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_platform_ban_user_idx": { + "name": "chat_platform_ban_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_platform_ban_expires_at_idx": { + "name": "chat_platform_ban_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_platform_ban_room_id_chat_room_id_fk": { + "name": "chat_platform_ban_room_id_chat_room_id_fk", + "tableFrom": "chat_platform_ban", + "tableTo": "chat_room", + "columnsFrom": ["room_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_room": { + "name": "chat_room", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "chat_room_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "join_code": { + "name": "join_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scheduled_deletion_at": { + "name": "scheduled_deletion_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chat_room_slug_key": { + "name": "chat_room_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_room_join_code_key": { + "name": "chat_room_join_code_key", + "columns": [ + { + "expression": "join_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_room_deleted_at_idx": { + "name": "chat_room_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_room_creator_public_deleted_at_idx": { + "name": "chat_room_creator_public_deleted_at_idx", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_public", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_room_scheduled_deletion_idx": { + "name": "chat_room_scheduled_deletion_idx", + "columns": [ + { + "expression": "scheduled_deletion_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat_room\".\"scheduled_deletion_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_room_ban": { + "name": "chat_room_ban", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "room_id": { + "name": "room_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "banned_by": { + "name": "banned_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lifted_at": { + "name": "lifted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lifted_by": { + "name": "lifted_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chat_room_ban_active_room_user_key": { + "name": "chat_room_ban_active_room_user_key", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat_room_ban\".\"lifted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_room_ban_room_idx": { + "name": "chat_room_ban_room_idx", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_room_ban_user_idx": { + "name": "chat_room_ban_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_room_ban_expires_at_idx": { + "name": "chat_room_ban_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_room_ban_room_id_chat_room_id_fk": { + "name": "chat_room_ban_room_id_chat_room_id_fk", + "tableFrom": "chat_room_ban", + "tableTo": "chat_room", + "columnsFrom": ["room_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_room_configuration": { + "name": "chat_room_configuration", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "room_id": { + "name": "room_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slow_mode": { + "name": "slow_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "slow_mode_seconds": { + "name": "slow_mode_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "read_only_mode": { + "name": "read_only_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "only_invited_can_join": { + "name": "only_invited_can_join", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "lock_room": { + "name": "lock_room", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "moderator_invite": { + "name": "moderator_invite", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_room_configuration_room_key": { + "name": "chat_room_configuration_room_key", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_room_configuration_room_id_chat_room_id_fk": { + "name": "chat_room_configuration_room_id_chat_room_id_fk", + "tableFrom": "chat_room_configuration", + "tableTo": "chat_room", + "columnsFrom": ["room_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_room_invite": { + "name": "chat_room_invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "room_id": { + "name": "room_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invitee_id": { + "name": "invitee_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "chat_room_invite_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "responded_at": { + "name": "responded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chat_room_invite_pending_room_invitee_key": { + "name": "chat_room_invite_pending_room_invitee_key", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invitee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat_room_invite\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_room_invite_invitee_status_idx": { + "name": "chat_room_invite_invitee_status_idx", + "columns": [ + { + "expression": "invitee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_room_invite_room_idx": { + "name": "chat_room_invite_room_idx", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_room_invite_room_id_chat_room_id_fk": { + "name": "chat_room_invite_room_id_chat_room_id_fk", + "tableFrom": "chat_room_invite", + "tableTo": "chat_room", + "columnsFrom": ["room_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_room_member": { + "name": "chat_room_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "room_id": { + "name": "room_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "chat_room_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "role_assigned_at": { + "name": "role_assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "account_closed_at": { + "name": "account_closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_room_member_room_user_key": { + "name": "chat_room_member_room_user_key", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_room_member_room_idx": { + "name": "chat_room_member_room_idx", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_room_member_user_idx": { + "name": "chat_room_member_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_room_member_room_id_chat_room_id_fk": { + "name": "chat_room_member_room_id_chat_room_id_fk", + "tableFrom": "chat_room_member", + "tableTo": "chat_room", + "columnsFrom": ["room_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_room_mute": { + "name": "chat_room_mute", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "room_id": { + "name": "room_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "muted_by": { + "name": "muted_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lifted_at": { + "name": "lifted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lifted_by": { + "name": "lifted_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chat_room_mute_active_room_user_key": { + "name": "chat_room_mute_active_room_user_key", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat_room_mute\".\"lifted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_room_mute_room_user_idx": { + "name": "chat_room_mute_room_user_idx", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_room_mute_expires_at_idx": { + "name": "chat_room_mute_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_room_mute_room_id_chat_room_id_fk": { + "name": "chat_room_mute_room_id_chat_room_id_fk", + "tableFrom": "chat_room_mute", + "tableTo": "chat_room", + "columnsFrom": ["room_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_room_remove": { + "name": "chat_room_remove", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "room_id": { + "name": "room_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "removed_by": { + "name": "removed_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_room_remove_room_created_at_idx": { + "name": "chat_room_remove_room_created_at_idx", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_room_remove_user_idx": { + "name": "chat_room_remove_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_room_remove_room_id_chat_room_id_fk": { + "name": "chat_room_remove_room_id_chat_room_id_fk", + "tableFrom": "chat_room_remove", + "tableTo": "chat_room", + "columnsFrom": ["room_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_room_rule": { + "name": "chat_room_rule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "room_id": { + "name": "room_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "order_num": { + "name": "order_num", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_room_rule_room_order_idx": { + "name": "chat_room_rule_room_order_idx", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_num", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_room_rule_room_idx": { + "name": "chat_room_rule_room_idx", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_room_rule_room_id_chat_room_id_fk": { + "name": "chat_room_rule_room_id_chat_room_id_fk", + "tableFrom": "chat_room_rule", + "tableTo": "chat_room", + "columnsFrom": ["room_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_user_block": { + "name": "chat_user_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "blocker_id": { + "name": "blocker_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "blocked_id": { + "name": "blocked_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chat_user_block_pair_key": { + "name": "chat_user_block_pair_key", + "columns": [ + { + "expression": "blocker_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "blocked_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat_user_block\".\"removed_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_user_block_blocker_idx": { + "name": "chat_user_block_blocker_idx", + "columns": [ + { + "expression": "blocker_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_user_ignore": { + "name": "chat_user_ignore", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ignorer_id": { + "name": "ignorer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ignored_id": { + "name": "ignored_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chat_user_ignore_pair_key": { + "name": "chat_user_ignore_pair_key", + "columns": [ + { + "expression": "ignorer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ignored_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat_user_ignore\".\"removed_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_user_ignore_ignorer_idx": { + "name": "chat_user_ignore_ignorer_idx", + "columns": [ + { + "expression": "ignorer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.chat_message_type": { + "name": "chat_message_type", + "schema": "public", + "values": ["user", "system"] + }, + "public.chat_moderation_scope": { + "name": "chat_moderation_scope", + "schema": "public", + "values": ["__global", "__all_public", "__all", "room"] + }, + "public.chat_room_category": { + "name": "chat_room_category", + "schema": "public", + "values": ["games-sports", "regions", "languages", "private-channels"] + }, + "public.chat_room_invite_status": { + "name": "chat_room_invite_status", + "schema": "public", + "values": ["pending", "accepted", "declined"] + }, + "public.chat_room_role": { + "name": "chat_room_role", + "schema": "public", + "values": ["member", "moderator", "owner"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/core/src/engagement/chat/drizzle/migrations/meta/_journal.json b/packages/core/src/engagement/chat/drizzle/migrations/meta/_journal.json index 8f89af835..7fdfd85a4 100644 --- a/packages/core/src/engagement/chat/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/engagement/chat/drizzle/migrations/meta/_journal.json @@ -120,6 +120,13 @@ "when": 1790853096446, "tag": "0016_chat_mute_active_unique", "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1791064264513, + "tag": "0017_chat_room_invite", + "breakpoints": true } ] } diff --git a/packages/core/src/engagement/chat/plugin.ts b/packages/core/src/engagement/chat/plugin.ts index 059b60477..6e2688b4d 100644 --- a/packages/core/src/engagement/chat/plugin.ts +++ b/packages/core/src/engagement/chat/plugin.ts @@ -30,6 +30,7 @@ import { ChatRoomNotFoundError, } from './service/chat-moderation.service.js'; import { ChatRoomMembershipService } from './service/chat-room-membership.service.js'; +import { ChatRoomInviteService } from './service/chat-room-invite.service.js'; import { ChatRoomBanService } from './service/chat-room-ban.service.js'; import { ChatRoomMuteService } from './service/chat-room-mute.service.js'; import { ChatModerationExpiryService } from './service/chat-moderation-expiry.service.js'; @@ -320,6 +321,12 @@ export default { return createChatRouter({ chatService, membershipService, + inviteService: new ChatRoomInviteService( + c.get(DRIZZLE), + c.get(EVENT_BUS), + c.get(IDENTITY_READER), + membershipService, + ), roomBanService: new ChatRoomBanService( c.get(DRIZZLE), c.get(EVENT_BUS), diff --git a/packages/core/src/engagement/chat/router/index.ts b/packages/core/src/engagement/chat/router/index.ts index 016edb268..2ec065500 100644 --- a/packages/core/src/engagement/chat/router/index.ts +++ b/packages/core/src/engagement/chat/router/index.ts @@ -53,6 +53,18 @@ import { ChatRoomNotFoundError, } from '../service/chat-moderation.service.js'; import { ChatRoomMembershipService } from '../service/chat-room-membership.service.js'; +import { ChatRoomInviteService } from '../service/chat-room-invite.service.js'; +import { + ChatRoomInviteForbiddenError, + ChatRoomInviteNotFoundError, + ChatRoomInvitePendingError, + ChatRoomInviteSelfError, + ChatRoomInviteeAlreadyMemberError, + ChatRoomInviteeBannedError, + ChatRoomInviteeNotPlayerError, + ChatRoomInviteeUnavailableError, + ChatRoomInviterBlockedError, +} from '../service/errors/chat-room-invite.errors.js'; import { ChatRoomBanService } from '../service/chat-room-ban.service.js'; import { ChatRoomMuteService } from '../service/chat-room-mute.service.js'; @@ -63,6 +75,18 @@ const JOIN_ROOM_RATE_LIMIT = { onUnavailable: 'deny', } as const; +const ROOM_INVITE_RATE_LIMIT = { + limit: 30, + windowMs: 15 * 60 * 1_000, + onUnavailable: 'deny', +} as const; + +const ROOM_INVITE_LOOKUP_RATE_LIMIT = { + limit: 120, + windowMs: 60 * 1_000, + onUnavailable: 'allow', +} as const; + const SEND_MESSAGE_RATE_LIMIT = { limit: 10, windowMs: 10_000, @@ -100,6 +124,7 @@ async function* messagesOf( export function createChatRouter({ chatService, membershipService, + inviteService, roomBanService, roomMuteService, moderationService, @@ -109,6 +134,7 @@ export function createChatRouter({ }: { chatService: ChatService; membershipService: ChatRoomMembershipService; + inviteService: ChatRoomInviteService; roomBanService: ChatRoomBanService; roomMuteService: ChatRoomMuteService; moderationService: ChatModeration; @@ -410,6 +436,95 @@ export function createChatRouter({ ); }), + inviteToRoom: os.inviteToRoom.handler(async ({ input, context }) => { + const actorId = getUserId(context); + await assertRateLimit( + limiter, + makeRateLimitKey(RATE_LIMIT_KEYS.CHAT_ROOM_INVITE, actorId), + ROOM_INVITE_RATE_LIMIT, + ); + return mapErrors( + { + BAD_REQUEST: [ChatRoomInviteSelfError, ChatRoomInviteeNotPlayerError], + NOT_FOUND: ChatRoomNotFoundError, + FORBIDDEN: [ + ChatRoomInviteForbiddenError, + ChatRoomInviteeUnavailableError, + ChatRoomInviteeBannedError, + ChatRoomLockedError, + ChatRoomBannedError, + ], + CONFLICT: [ChatRoomInviteeAlreadyMemberError, ChatRoomInvitePendingError], + }, + () => + inviteService.inviteToRoom({ + actorId, + roomId: input.roomId, + userId: input.userId, + ...context.clientMeta, + }), + ); + }), + + searchRoomInviteCandidates: os.searchRoomInviteCandidates.handler( + async ({ input, context }) => { + const actorId = getUserId(context); + await assertRateLimit( + limiter, + makeRateLimitKey(RATE_LIMIT_KEYS.CHAT_ROOM_INVITE_LOOKUP, actorId), + ROOM_INVITE_LOOKUP_RATE_LIMIT, + ); + return mapErrors( + { NOT_FOUND: ChatRoomNotFoundError, FORBIDDEN: ChatRoomInviteForbiddenError }, + () => inviteService.searchRoomInviteCandidates({ actorId, ...input }), + ); + }, + ), + + getRoomInviteStatuses: os.getRoomInviteStatuses.handler(async ({ input, context }) => { + const actorId = getUserId(context); + await assertRateLimit( + limiter, + makeRateLimitKey(RATE_LIMIT_KEYS.CHAT_ROOM_INVITE_LOOKUP, actorId), + ROOM_INVITE_LOOKUP_RATE_LIMIT, + ); + return mapErrors( + { NOT_FOUND: ChatRoomNotFoundError, FORBIDDEN: ChatRoomInviteForbiddenError }, + () => inviteService.getRoomInviteStatuses({ actorId, ...input }), + ); + }), + + listMyRoomInvites: os.listMyRoomInvites.handler(({ context }) => + inviteService.listMyRoomInvites(getUserId(context)), + ), + + acceptRoomInvite: os.acceptRoomInvite.handler(({ input, context }) => { + const userId = getUserId(context); + return mapErrors( + { + NOT_FOUND: [ChatRoomInviteNotFoundError, ChatRoomNotFoundError], + FORBIDDEN: [ChatRoomBannedError, ChatRoomLockedError, ChatRoomInviterBlockedError], + }, + () => + inviteService.acceptRoomInvite({ + userId, + inviteId: input.inviteId, + ...context.clientMeta, + }), + ); + }), + + declineRoomInvite: os.declineRoomInvite.handler(({ input, context }) => { + const userId = getUserId(context); + return mapErrors({ NOT_FOUND: ChatRoomInviteNotFoundError }, () => + inviteService.declineRoomInvite({ + userId, + inviteId: input.inviteId, + ...context.clientMeta, + }), + ); + }), + getRoom: os.getRoom.handler(({ input, context }) => mapErrors( { diff --git a/packages/core/src/engagement/chat/schema/index.ts b/packages/core/src/engagement/chat/schema/index.ts index 5fefacb5e..e5e3af5a1 100644 --- a/packages/core/src/engagement/chat/schema/index.ts +++ b/packages/core/src/engagement/chat/schema/index.ts @@ -14,6 +14,7 @@ import { import { CHAT_ROOM_CATEGORIES, CHAT_ROOM_ROLES, + CHAT_ROOM_INVITE_STATUSES, CHAT_MODERATION_SCOPE_VALUES, } from '../contract/index.js'; import { CHAT_MESSAGE_TYPES, ChatAttachmentSchema } from '@openora/core/contracts'; @@ -24,6 +25,7 @@ export const chatRoomRole = pgEnum('chat_room_role', CHAT_ROOM_ROLES); export const chatRoomCategory = pgEnum('chat_room_category', CHAT_ROOM_CATEGORIES); export const chatMessageType = pgEnum('chat_message_type', CHAT_MESSAGE_TYPES); export const chatModerationScope = pgEnum('chat_moderation_scope', CHAT_MODERATION_SCOPE_VALUES); +export const chatRoomInviteStatus = pgEnum('chat_room_invite_status', CHAT_ROOM_INVITE_STATUSES); export const chatRoom = pgTable( 'chat_room', @@ -143,6 +145,28 @@ export const chatRoomMember = pgTable( ], ); +export const chatRoomInvite = pgTable( + 'chat_room_invite', + { + id: uuid().primaryKey().defaultRandom(), + roomId: uuid() + .notNull() + .references(() => chatRoom.id, { onDelete: 'cascade' }), + inviterId: uuid().notNull(), + inviteeId: uuid().notNull(), + status: chatRoomInviteStatus().notNull().default('pending'), + createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), + respondedAt: timestamp({ withTimezone: true }), + }, + (t) => [ + uniqueIndex('chat_room_invite_pending_room_invitee_key') + .on(t.roomId, t.inviteeId) + .where(sql`${t.status} = 'pending'`), + index('chat_room_invite_invitee_status_idx').on(t.inviteeId, t.status), + index('chat_room_invite_room_idx').on(t.roomId), + ], +); + export const chatRoomRule = pgTable( 'chat_room_rule', { @@ -306,6 +330,7 @@ export type ChatMessage = typeof chatMessage.$inferSelect; export type ChatUserBlock = typeof chatUserBlock.$inferSelect; export type ChatUserIgnore = typeof chatUserIgnore.$inferSelect; export type ChatRoomMember = typeof chatRoomMember.$inferSelect; +export type ChatRoomInvite = typeof chatRoomInvite.$inferSelect; export type ChatRoomRule = typeof chatRoomRule.$inferSelect; export type ChatRoomConfiguration = typeof chatRoomConfiguration.$inferSelect; export type ChatRoomBan = typeof chatRoomBan.$inferSelect; diff --git a/packages/core/src/engagement/chat/service/chat-room-invite.service.ts b/packages/core/src/engagement/chat/service/chat-room-invite.service.ts new file mode 100644 index 000000000..70ab72c63 --- /dev/null +++ b/packages/core/src/engagement/chat/service/chat-room-invite.service.ts @@ -0,0 +1,561 @@ +import { + and, + asc, + desc, + eq, + exists, + gt, + ilike, + inArray, + isNull, + ne, + notExists, + notInArray, + or, + sql, +} from 'drizzle-orm'; +import { + DrizzleService, + likeContains, + likePrefix, + serializeRow, + withAdvisoryXactLock, +} from '@openora/core/server'; +import type { DrizzleDb, DrizzleTx, EventBus } from '@openora/core/server'; +import type { ClientMeta, IdentityReader, Uuid } from '@openora/core/contracts'; +import { user } from '@openora/core/pam/schema/identity'; +import { player } from '@openora/core/pam/schema/profile'; +import { + chatPlatformBan, + chatRoom, + chatRoomBan, + chatRoomConfiguration, + chatRoomInvite, + chatRoomMember, + chatUserBlock, +} from '../schema/index.js'; +import { ROOM_INVITE_EXPIRY_DAYS } from '../contract/constants.js'; +import type { + ChatRoomInviteCandidate, + ChatRoomInviteCandidateStatus, + ChatRoomInviteLookup, +} from '../contract/index.js'; +import { platformScopesFor } from '../moderation/index.js'; +import { + ChatRoomBannedError, + ChatRoomLockedError, + ChatRoomNotFoundError, +} from './errors/chat-moderation.errors.js'; +import { + ChatRoomInviteForbiddenError, + ChatRoomInviteNotFoundError, + ChatRoomInvitePendingError, + ChatRoomInviteSelfError, + ChatRoomInviteeAlreadyMemberError, + ChatRoomInviteeBannedError, + ChatRoomInviteeNotPlayerError, + ChatRoomInviteeUnavailableError, + ChatRoomInviterBlockedError, +} from './errors/chat-room-invite.errors.js'; +import type { ChatRoomMembershipService } from './chat-room-membership.service.js'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +const MY_INVITES_LIMIT = 100; + +const UNAVAILABLE_PLAYER_STATUSES = ['suspended', 'closed'] as const; + +type Db = DrizzleDb | DrizzleTx; + +const invitingRole = or( + eq(chatRoomMember.role, 'owner'), + and(eq(chatRoomMember.role, 'moderator'), eq(chatRoomConfiguration.moderatorInvite, true)), +); + +// An invite lapses after the expiry window or once its inviter may no longer invite. +function liveInvite(db: Db, now: Date) { + return and( + eq(chatRoomInvite.status, 'pending'), + gt(chatRoomInvite.createdAt, new Date(now.getTime() - ROOM_INVITE_EXPIRY_DAYS * DAY_MS)), + exists( + db + .select({ id: chatRoomMember.id }) + .from(chatRoomMember) + .leftJoin(chatRoomConfiguration, eq(chatRoomConfiguration.roomId, chatRoomMember.roomId)) + .where( + and( + eq(chatRoomMember.roomId, chatRoomInvite.roomId), + eq(chatRoomMember.userId, chatRoomInvite.inviterId), + invitingRole, + ), + ), + ), + ); +} + +function activeRoomBan(roomId: Uuid, now: Date) { + return and( + eq(chatRoomBan.roomId, roomId), + isNull(chatRoomBan.liftedAt), + or(isNull(chatRoomBan.expiresAt), gt(chatRoomBan.expiresAt, now)), + ); +} + +function activePlatformBan(roomId: Uuid, now: Date) { + return and( + isNull(chatPlatformBan.liftedAt), + or(isNull(chatPlatformBan.expiresAt), gt(chatPlatformBan.expiresAt, now)), + or( + inArray(chatPlatformBan.scope, platformScopesFor('private')), + and(eq(chatPlatformBan.scope, 'room'), eq(chatPlatformBan.roomId, roomId)), + ), + ); +} + +function activeBlock(blockerId: Uuid | typeof user.id, blockedId: Uuid) { + return and( + eq(chatUserBlock.blockerId, blockerId), + eq(chatUserBlock.blockedId, blockedId), + isNull(chatUserBlock.removedAt), + ); +} + +function aliveInvitableRoom(roomId: Uuid) { + return and( + eq(chatRoom.id, roomId), + eq(chatRoom.isPublic, false), + isNull(chatRoom.deletedAt), + isNull(chatRoom.scheduledDeletionAt), + ); +} + +// Blocks, platform bans and account state only hide a player who would otherwise read as +// available; a member, room-banned or invited player keeps that status, so nothing leaks. +function visibleCandidate(db: Db, actorId: Uuid, roomId: Uuid, now: Date) { + const knownToRoom = or( + exists( + db + .select({ id: chatRoomMember.id }) + .from(chatRoomMember) + .where(and(eq(chatRoomMember.roomId, roomId), eq(chatRoomMember.userId, user.id))), + ), + exists( + db + .select({ id: chatRoomBan.id }) + .from(chatRoomBan) + .where(and(activeRoomBan(roomId, now), eq(chatRoomBan.userId, user.id))), + ), + exists( + db + .select({ id: chatRoomInvite.id }) + .from(chatRoomInvite) + .where( + and( + eq(chatRoomInvite.roomId, roomId), + eq(chatRoomInvite.inviteeId, user.id), + liveInvite(db, now), + ), + ), + ), + ); + const unmasked = and( + notInArray(player.status, [...UNAVAILABLE_PLAYER_STATUSES]), + notExists( + db + .select({ blockerId: chatUserBlock.blockerId }) + .from(chatUserBlock) + .where(activeBlock(user.id, actorId)), + ), + notExists( + db + .select({ id: chatPlatformBan.id }) + .from(chatPlatformBan) + .where(and(activePlatformBan(roomId, now), eq(chatPlatformBan.userId, user.id))), + ), + ); + return and(eq(user.role, 'player'), or(knownToRoom, unmasked)); +} + +export class ChatRoomInviteService { + constructor( + private readonly drizzle: DrizzleService, + private readonly events: EventBus, + private readonly identityReader: IdentityReader, + private readonly membership: Pick, + ) {} + + // A non-member gets NOT_FOUND so a private room's existence is not revealed. + private async assertCanInvite(db: Db, actorId: Uuid, roomId: Uuid) { + const [room] = await db + .select({ + name: chatRoom.name, + inviterUsername: user.username, + canInvite: sql`coalesce(${invitingRole}, false)`, + lockRoom: chatRoomConfiguration.lockRoom, + }) + .from(chatRoom) + .innerJoin( + chatRoomMember, + and(eq(chatRoomMember.roomId, chatRoom.id), eq(chatRoomMember.userId, actorId)), + ) + .innerJoin(user, eq(user.id, chatRoomMember.userId)) + .leftJoin(chatRoomConfiguration, eq(chatRoomConfiguration.roomId, chatRoom.id)) + .where(aliveInvitableRoom(roomId)) + .limit(1); + if (!room) { + throw new ChatRoomNotFoundError(roomId); + } + if (!room.canInvite) { + throw new ChatRoomInviteForbiddenError(roomId); + } + return { + name: room.name, + inviterUsername: room.inviterUsername, + lockRoom: room.lockRoom === true, + }; + } + + private async statusesFor( + db: Db, + roomId: Uuid, + userIds: Uuid[], + ): Promise> { + const now = new Date(); + const [members, roomBans, pending] = await Promise.all([ + db + .select({ userId: chatRoomMember.userId }) + .from(chatRoomMember) + .where(and(eq(chatRoomMember.roomId, roomId), inArray(chatRoomMember.userId, userIds))), + db + .select({ userId: chatRoomBan.userId }) + .from(chatRoomBan) + .where(and(activeRoomBan(roomId, now), inArray(chatRoomBan.userId, userIds))), + db + .select({ userId: chatRoomInvite.inviteeId }) + .from(chatRoomInvite) + .where( + and( + eq(chatRoomInvite.roomId, roomId), + inArray(chatRoomInvite.inviteeId, userIds), + liveInvite(db, now), + ), + ), + ]); + const memberIds = new Set(members.map((row) => row.userId)); + const bannedIds = new Set(roomBans.map((row) => row.userId)); + const invitedIds = new Set(pending.map((row) => row.userId)); + return new Map( + userIds.map((id) => { + if (memberIds.has(id)) { + return [id, 'member']; + } + if (bannedIds.has(id)) { + return [id, 'banned']; + } + return [id, invitedIds.has(id) ? 'invited' : 'available']; + }), + ); + } + + async inviteToRoom({ + actorId, + roomId, + userId, + ip, + userAgent, + }: { actorId: Uuid; roomId: Uuid; userId: Uuid } & ClientMeta) { + if (actorId === userId) { + throw new ChatRoomInviteSelfError(); + } + const { invite, room } = await this.drizzle.db.transaction((t) => + withAdvisoryXactLock(t, `chat-room:${roomId}`, async () => { + const now = new Date(); + const room = await this.assertCanInvite(t, actorId, roomId); + if (room.lockRoom) { + throw new ChatRoomLockedError(roomId); + } + const [inviterBan] = await t + .select({ id: chatPlatformBan.id }) + .from(chatPlatformBan) + .where(and(eq(chatPlatformBan.userId, actorId), activePlatformBan(roomId, now))) + .limit(1); + if (inviterBan) { + throw new ChatRoomBannedError(roomId); + } + const [target] = await t + .select({ role: user.role, status: player.status }) + .from(user) + .leftJoin(player, eq(player.userId, user.id)) + .where(eq(user.id, userId)) + .limit(1); + if (!target || target.role !== 'player' || target.status === null) { + throw new ChatRoomInviteeNotPlayerError(userId); + } + const status = (await this.statusesFor(t, roomId, [userId])).get(userId); + if (status === 'member') { + throw new ChatRoomInviteeAlreadyMemberError(userId); + } + if (status === 'banned') { + throw new ChatRoomInviteeBannedError(userId); + } + if (status === 'invited') { + throw new ChatRoomInvitePendingError(userId); + } + const [visible] = await t + .select({ id: user.id }) + .from(user) + .innerJoin(player, eq(player.userId, user.id)) + .where(and(eq(user.id, userId), visibleCandidate(t, actorId, roomId, now))) + .limit(1); + if (!visible) { + throw new ChatRoomInviteeUnavailableError(userId); + } + // A lapsed invite still holds the one-pending slot in the partial unique index. + await t + .delete(chatRoomInvite) + .where( + and( + eq(chatRoomInvite.roomId, roomId), + eq(chatRoomInvite.inviteeId, userId), + eq(chatRoomInvite.status, 'pending'), + ), + ); + const [invite] = await t + .insert(chatRoomInvite) + .values({ roomId, inviterId: actorId, inviteeId: userId }) + .returning(); + if (!invite) { + throw new Error('chat room invite insert returned no row'); + } + return { invite, room }; + }), + ); + this.events.emit('chat.room.invite.sent', { + inviteId: invite.id, + roomId, + inviterId: actorId, + inviteeId: userId, + inviterUsername: room.inviterUsername, + roomName: room.name, + playerId: await this.identityReader.getPlayerIdByUserIdSafe(actorId), + ip: ip ?? null, + userAgent: userAgent ?? null, + }); + return serializeRow(invite, { dateFields: ['createdAt', 'respondedAt'] }); + } + + async searchRoomInviteCandidates({ + actorId, + roomId, + q, + limit, + }: { + actorId: Uuid; + roomId: Uuid; + q: string; + limit: number; + }): Promise { + const db = this.drizzle.db; + await this.assertCanInvite(db, actorId, roomId); + const rows = await db + .select({ userId: user.id, username: user.username, avatarUrl: user.image }) + .from(user) + .innerJoin(player, eq(player.userId, user.id)) + .where( + and( + ne(user.id, actorId), + ilike(user.username, likeContains(q)), + visibleCandidate(db, actorId, roomId, new Date()), + ), + ) + .orderBy( + sql`case when ${user.username} ilike ${likePrefix(q)} then 0 else 1 end`, + asc(user.username), + ) + .limit(limit); + if (rows.length === 0) { + return []; + } + const statuses = await this.statusesFor( + db, + roomId, + rows.map((row) => row.userId), + ); + return rows.map((row) => ({ ...row, inviteStatus: statuses.get(row.userId) ?? 'available' })); + } + + async getRoomInviteStatuses({ + actorId, + roomId, + userIds, + }: { + actorId: Uuid; + roomId: Uuid; + userIds: Uuid[]; + }): Promise { + const db = this.drizzle.db; + await this.assertCanInvite(db, actorId, roomId); + const ids = [...new Set(userIds)]; + const [visible, statuses] = await Promise.all([ + db + .select({ userId: user.id }) + .from(user) + .innerJoin(player, eq(player.userId, user.id)) + .where(and(inArray(user.id, ids), visibleCandidate(db, actorId, roomId, new Date()))), + this.statusesFor(db, roomId, ids), + ]); + const visibleIds = new Set(visible.map((row) => row.userId)); + return ids.map((id) => ({ + userId: id, + inviteStatus: visibleIds.has(id) ? (statuses.get(id) ?? 'available') : 'unavailable', + })); + } + + async listMyRoomInvites(userId: Uuid) { + const db = this.drizzle.db; + const rows = await db + .select({ + id: chatRoomInvite.id, + roomId: chatRoomInvite.roomId, + roomName: chatRoom.name, + inviterId: chatRoomInvite.inviterId, + inviterUsername: user.username, + createdAt: chatRoomInvite.createdAt, + }) + .from(chatRoomInvite) + .innerJoin( + chatRoom, + and( + eq(chatRoom.id, chatRoomInvite.roomId), + eq(chatRoom.isPublic, false), + isNull(chatRoom.deletedAt), + isNull(chatRoom.scheduledDeletionAt), + ), + ) + .leftJoin(user, eq(user.id, chatRoomInvite.inviterId)) + .where( + and( + eq(chatRoomInvite.inviteeId, userId), + liveInvite(db, new Date()), + notExists( + db + .select({ id: chatRoomMember.id }) + .from(chatRoomMember) + .where( + and( + eq(chatRoomMember.roomId, chatRoomInvite.roomId), + eq(chatRoomMember.userId, userId), + ), + ), + ), + ), + ) + .orderBy(desc(chatRoomInvite.createdAt)) + .limit(MY_INVITES_LIMIT); + return rows.map((row) => serializeRow(row, { dateFields: ['createdAt'] })); + } + + async acceptRoomInvite({ + userId, + inviteId, + ip, + userAgent, + }: { userId: Uuid; inviteId: Uuid } & ClientMeta) { + const addressedToCaller = (db: Db, now: Date) => + and( + eq(chatRoomInvite.id, inviteId), + eq(chatRoomInvite.inviteeId, userId), + liveInvite(db, now), + ); + const [invite] = await this.drizzle.db + .select({ roomId: chatRoomInvite.roomId, inviterId: chatRoomInvite.inviterId }) + .from(chatRoomInvite) + .where(addressedToCaller(this.drizzle.db, new Date())) + .limit(1); + if (!invite) { + throw new ChatRoomInviteNotFoundError(inviteId); + } + const room = await this.membership.joinByInvite({ + roomId: invite.roomId, + userId, + ip, + userAgent, + inTransaction: async (t) => { + const now = new Date(); + const [locked] = await t + .select({ id: chatRoomInvite.id }) + .from(chatRoomInvite) + .where(addressedToCaller(t, now)) + .for('update', { of: chatRoomInvite }) + .limit(1); + if (!locked) { + throw new ChatRoomInviteNotFoundError(inviteId); + } + const [platformBan] = await t + .select({ id: chatPlatformBan.id }) + .from(chatPlatformBan) + .where(and(eq(chatPlatformBan.userId, userId), activePlatformBan(invite.roomId, now))) + .limit(1); + if (platformBan) { + throw new ChatRoomBannedError(invite.roomId); + } + const [block] = await t + .select({ blockerId: chatUserBlock.blockerId }) + .from(chatUserBlock) + .where(activeBlock(userId, invite.inviterId)) + .limit(1); + if (block) { + throw new ChatRoomInviterBlockedError(); + } + await t + .update(chatRoomInvite) + .set({ status: 'accepted', respondedAt: now }) + .where(eq(chatRoomInvite.id, inviteId)); + }, + }); + this.events.emit('chat.room.invite.accepted', { + inviteId, + roomId: invite.roomId, + inviterId: invite.inviterId, + inviteeId: userId, + playerId: await this.identityReader.getPlayerIdByUserIdSafe(userId), + ip: ip ?? null, + userAgent: userAgent ?? null, + }); + return room; + } + + async declineRoomInvite({ + userId, + inviteId, + ip, + userAgent, + }: { userId: Uuid; inviteId: Uuid } & ClientMeta) { + const db = this.drizzle.db; + const now = new Date(); + const [declined] = await db + .update(chatRoomInvite) + .set({ status: 'declined', respondedAt: now }) + .where( + and( + eq(chatRoomInvite.id, inviteId), + eq(chatRoomInvite.inviteeId, userId), + liveInvite(db, now), + ), + ) + .returning({ roomId: chatRoomInvite.roomId, inviterId: chatRoomInvite.inviterId }); + if (!declined) { + throw new ChatRoomInviteNotFoundError(inviteId); + } + this.events.emit('chat.room.invite.declined', { + inviteId, + roomId: declined.roomId, + inviterId: declined.inviterId, + inviteeId: userId, + playerId: await this.identityReader.getPlayerIdByUserIdSafe(userId), + ip: ip ?? null, + userAgent: userAgent ?? null, + }); + return { success: true } as const; + } +} diff --git a/packages/core/src/engagement/chat/service/chat-room-membership.service.ts b/packages/core/src/engagement/chat/service/chat-room-membership.service.ts index 487a86ecb..2b36216d0 100644 --- a/packages/core/src/engagement/chat/service/chat-room-membership.service.ts +++ b/packages/core/src/engagement/chat/service/chat-room-membership.service.ts @@ -14,6 +14,7 @@ import { type RealtimeTransport, type Uuid, } from '@openora/core/contracts'; +import { user } from '@openora/core/pam/schema/identity'; import { chatRoom, chatRoomBan, @@ -22,12 +23,14 @@ import { chatRoomRemove, } from '../schema/index.js'; import type { + ChatMemberJoinedSignal, ChatMemberRoleChangedSignal, ChatRoomAssignableRole, ChatRoomRole, ChatRoomScheduledForDeletionSignal, } from '../contract/index.js'; import { + CHAT_MEMBER_JOINED_SIGNAL, CHAT_MEMBER_ROLE_CHANGED_SIGNAL, CHAT_ROOM_SCHEDULED_FOR_DELETION_SIGNAL, OWNERLESS_ROOM_RETENTION_DAYS, @@ -47,6 +50,8 @@ import { revokeChannelBestEffort, revokeRoomChannelBestEffort } from './channel- const MODERATOR_ROLES = ['moderator', 'owner'] as const; +const STAFF_USER_ROLES = ['admin', 'super-admin'] as const; + const DAY_MS = 24 * 60 * 60 * 1000; const logger = createLogger('chat'); @@ -77,13 +82,20 @@ export class ChatRoomMembershipService { private readonly identityReader: IdentityReader, ) {} - private async join( - roomId: Uuid, - userId: Uuid, - meta: ClientMeta & { adminId?: Uuid }, - predicate: ReturnType, - ) { - const { room, inserted } = await this.drizzle.db.transaction((t) => + private async join({ + roomId, + userId, + meta, + predicate, + inTransaction, + }: { + roomId: Uuid; + userId: Uuid; + meta: ClientMeta & { adminId?: Uuid }; + predicate: ReturnType; + inTransaction?: (t: DrizzleTx) => Promise; + }) { + const { room, inserted, joinerRole } = await this.drizzle.db.transaction((t) => withAdvisoryXactLock(t, `chat-room:${roomId}`, async () => { const [room] = await t.select().from(chatRoom).where(predicate).limit(1); if (!room) { @@ -121,12 +133,17 @@ export class ChatRoomMembershipService { if (ban) { throw new ChatRoomBannedError(roomId); } + await inTransaction?.(t); const inserted = await t .insert(chatRoomMember) .values({ roomId, userId }) .onConflictDoNothing() .returning(); - return { room, inserted }; + const [joiner] = + inserted.length > 0 + ? await t.select({ role: user.role }).from(user).where(eq(user.id, userId)).limit(1) + : []; + return { room, inserted, joinerRole: joiner?.role ?? null }; }), ); if (inserted.length > 0) { @@ -138,6 +155,10 @@ export class ChatRoomMembershipService { ip: meta.ip ?? null, userAgent: meta.userAgent ?? null, }); + // The member list hides staff from ordinary viewers, so their joins are not signalled. + if (!STAFF_USER_ROLES.some((role) => role === joinerRole)) { + await this.signalMemberJoined(roomId, userId); + } } return toRoom(room); } @@ -152,35 +173,65 @@ export class ChatRoomMembershipService { if (!candidate) { throw new ChatRoomJoinCodeNotFoundError(joinCode); } - return this.join( - candidate.id, + return this.join({ + roomId: candidate.id, userId, - { ip, userAgent }, - and( + meta: { ip, userAgent }, + predicate: and( eq(chatRoom.id, candidate.id), eq(chatRoom.joinCode, joinCode), isNull(chatRoom.deletedAt), ), - ); + }); }); } + // `inTransaction` runs under the room lock after the lock and ban checks, before the insert. + joinByInvite({ + roomId, + userId, + ip, + userAgent, + inTransaction, + }: { + roomId: Uuid; + userId: Uuid; + inTransaction: (t: DrizzleTx) => Promise; + } & ClientMeta) { + return this.join({ + roomId, + userId, + meta: { ip, userAgent }, + predicate: and( + eq(chatRoom.id, roomId), + eq(chatRoom.isPublic, false), + isNull(chatRoom.deletedAt), + isNull(chatRoom.scheduledDeletionAt), + ), + inTransaction, + }); + } + joinPublicRoom({ roomId, userId, ip, userAgent }: { roomId: Uuid; userId: Uuid } & ClientMeta) { - return this.join( + return this.join({ roomId, userId, - { ip, userAgent }, - and(eq(chatRoom.id, roomId), eq(chatRoom.isPublic, true), isNull(chatRoom.deletedAt)), - ); + meta: { ip, userAgent }, + predicate: and( + eq(chatRoom.id, roomId), + eq(chatRoom.isPublic, true), + isNull(chatRoom.deletedAt), + ), + }); } adminJoinRoom({ roomId, userId, ip, userAgent }: { roomId: Uuid; userId: Uuid } & ClientMeta) { - return this.join( + return this.join({ roomId, userId, - { ip, userAgent, adminId: userId }, - and(eq(chatRoom.id, roomId), isNull(chatRoom.deletedAt)), - ); + meta: { ip, userAgent, adminId: userId }, + predicate: and(eq(chatRoom.id, roomId), isNull(chatRoom.deletedAt)), + }); } async leaveRoom({ userId, roomId, ip, userAgent }: { userId: Uuid; roomId: Uuid } & ClientMeta) { @@ -404,6 +455,15 @@ export class ChatRoomMembershipService { return { success: true } as const; } + private async signalMemberJoined(roomId: Uuid, userId: Uuid) { + const payload: ChatMemberJoinedSignal = { roomId, userId }; + try { + await this.transport?.signal?.(chatChannel(roomId), CHAT_MEMBER_JOINED_SIGNAL, payload); + } catch (err: unknown) { + logger.error({ err, roomId, userId }, 'chat member-joined signal failed'); + } + } + private async signalRoleChange(roomId: Uuid, userId: Uuid, role: ChatRoomRole) { const payload: ChatMemberRoleChangedSignal = { roomId, userId, role }; try { diff --git a/packages/core/src/engagement/chat/service/errors/chat-room-invite.errors.ts b/packages/core/src/engagement/chat/service/errors/chat-room-invite.errors.ts new file mode 100644 index 000000000..2c6d2ea09 --- /dev/null +++ b/packages/core/src/engagement/chat/service/errors/chat-room-invite.errors.ts @@ -0,0 +1,44 @@ +import { createDomainError, makeNotFoundError } from '@openora/core/server'; +import type { Uuid } from '@openora/core/contracts'; + +export const ChatRoomInviteNotFoundError = makeNotFoundError('ChatRoomInvite'); +export const ChatRoomInviteForbiddenError = createDomainError( + 'ChatRoomInviteForbiddenError', + (roomId: Uuid) => `You cannot invite players to room: ${roomId}`, + { reason: 'forbidden' }, +); +export const ChatRoomInviteSelfError = createDomainError( + 'ChatRoomInviteSelfError', + () => 'You cannot invite yourself', + { reason: 'self' }, +); +export const ChatRoomInviteeNotPlayerError = createDomainError( + 'ChatRoomInviteeNotPlayerError', + (userId: Uuid) => `Only players can be invited: ${userId}`, + { reason: 'not_player' }, +); +export const ChatRoomInviteeUnavailableError = createDomainError( + 'ChatRoomInviteeUnavailableError', + (userId: Uuid) => `This player cannot be invited: ${userId}`, + { reason: 'unavailable' }, +); +export const ChatRoomInviteeBannedError = createDomainError( + 'ChatRoomInviteeBannedError', + (userId: Uuid) => `This player is banned from the room: ${userId}`, + { reason: 'banned' }, +); +export const ChatRoomInviteeAlreadyMemberError = createDomainError( + 'ChatRoomInviteeAlreadyMemberError', + (userId: Uuid) => `This player is already a member: ${userId}`, + { reason: 'member' }, +); +export const ChatRoomInvitePendingError = createDomainError( + 'ChatRoomInvitePendingError', + (userId: Uuid) => `This player already has a pending invite: ${userId}`, + { reason: 'invited' }, +); +export const ChatRoomInviterBlockedError = createDomainError( + 'ChatRoomInviterBlockedError', + () => 'You have blocked the player who sent this invite', + { reason: 'blocked' }, +); diff --git a/packages/core/src/engagement/notifications/contract/index.ts b/packages/core/src/engagement/notifications/contract/index.ts index af4138bb1..e591dc1e5 100644 --- a/packages/core/src/engagement/notifications/contract/index.ts +++ b/packages/core/src/engagement/notifications/contract/index.ts @@ -27,6 +27,7 @@ export const NOTIFICATION_TYPES = [ 'chat.mention', 'chat.room.ownership_transferred', 'chat.room.scheduled_for_deletion', + 'chat.room_invite.received', ] as const; export const NotificationTypeSchema = z.enum(NOTIFICATION_TYPES); export type NotificationType = z.infer; diff --git a/packages/core/src/engagement/notifications/plugin.ts b/packages/core/src/engagement/notifications/plugin.ts index 77453e66f..274b1151a 100644 --- a/packages/core/src/engagement/notifications/plugin.ts +++ b/packages/core/src/engagement/notifications/plugin.ts @@ -378,6 +378,14 @@ export const notificationEventMap: NotificationMapEntry[] = [ data: compactData({ roomId: p.roomId, messageId: p.messageId }), })), + mapEvent('chat.room.invite.sent', (p) => ({ + userId: p.inviteeId, + type: 'chat.room_invite.received', + title: 'Room invite', + body: `${p.inviterUsername} invited you to ${p.roomName}`, + data: { inviteId: p.inviteId, roomId: p.roomId, inviterId: p.inviterId }, + })), + // Pre-existing types: in-app only, unchanged from before the declarative-map refactor. mapEvent('social.friend_request.sent', (p) => ({ userId: p.addresseeId, diff --git a/packages/testing/src/__tests__/chat-room-invites.e2e.test.ts b/packages/testing/src/__tests__/chat-room-invites.e2e.test.ts new file mode 100644 index 000000000..d6240d341 --- /dev/null +++ b/packages/testing/src/__tests__/chat-room-invites.e2e.test.ts @@ -0,0 +1,697 @@ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { randomInt, randomUUID } from 'node:crypto'; +import { and, eq } from 'drizzle-orm'; +import { DRIZZLE, loadExtensions } from '@openora/core/server'; +import { CHAT_REALTIME_TRANSPORT, chatChannel } from '@openora/core/contracts'; +import { user } from '@openora/core/pam/schema/identity'; +import { player } from '@openora/core/pam/schema/profile'; +import { chatRoomInvite } from '@openora/core/engagement/schema/chat'; +import { + CHAT_MEMBER_JOINED_SIGNAL, + ChatRoomInviteCandidateSchema, + ChatRoomInviteLookupSchema, + ChatRoomInviteSchema, + ChatRoomMemberSchema, + ChatRoomSchema, + MyChatRoomInviteSchema, + type ChatRoom, +} from '@openora/core/engagement/contracts/chat'; +import { + setupTestDb, + bootTestApp, + asAdmin, + registerAndMaterializePlayer, + seedMinimal, + type TestDb, + type TestApp, + type TestClient, +} from '../index.js'; + +let db: TestDb; +let app: TestApp; +let admin: TestClient; + +const WAIT = { timeout: 10_000, interval: 100 }; + +type Chatter = Awaited> & { username: string }; + +async function registerChatter(prefix: string, email?: string): Promise { + const username = `${prefix.slice(0, 7)}_${randomInt(10 ** 9, 10 ** 10)}`; + const registered = await registerAndMaterializePlayer(app, { + email: email ?? `${username}@e2e.test`, + username, + }); + return { ...registered, username }; +} + +async function createRoom(owner: Chatter) { + const created = await owner.client.post('/chat/rooms/private', { name: `room-${randomUUID()}` }); + expect(created.status).toBe(200); + return ChatRoomSchema.parse(await created.json()); +} + +async function joinByCode(member: Chatter, room: ChatRoom) { + const joined = await member.client.post('/chat/rooms/join', { joinCode: room.joinCode }); + expect(joined.status).toBe(200); +} + +async function configureRoom( + owner: Chatter, + room: ChatRoom, + values: { moderatorInvite?: boolean; lockRoom?: boolean }, +) { + const res = await owner.client.patch(`/chat/rooms/${room.id}/configuration`, values); + expect(res.status).toBe(200); +} + +async function addModerator(owner: Chatter, room: ChatRoom) { + const moderator = await registerChatter('keeper'); + await joinByCode(moderator, room); + const promoted = await owner.client.post( + `/chat/rooms/${room.id}/members/${moderator.userId}/role`, + { role: 'moderator' }, + ); + expect(promoted.status).toBe(200); + return moderator; +} + +function invite(actor: Chatter, room: ChatRoom, userId: string) { + return actor.client.post(`/chat/rooms/${room.id}/invites`, { userId }); +} + +async function sendInvite(actor: Chatter, room: ChatRoom, invitee: Chatter) { + const res = await invite(actor, room, invitee.userId); + expect(res.status).toBe(200); + return ChatRoomInviteSchema.parse(await res.json()); +} + +async function expectRejected(res: Response, status: number, reason: string) { + expect(res.status).toBe(status); + const body = (await res.json()) as { data?: { reason?: string } }; + expect(body.data?.reason).toBe(reason); +} + +async function myInvites(invitee: Chatter) { + const res = await invitee.client.get('/chat/invites'); + expect(res.status).toBe(200); + return MyChatRoomInviteSchema.array().parse(await res.json()); +} + +async function roomMemberIds(owner: Chatter, room: ChatRoom) { + const res = await owner.client.get(`/chat/rooms/${room.id}/members`); + expect(res.status).toBe(200); + return ChatRoomMemberSchema.array() + .parse(await res.json()) + .map((member) => member.userId); +} + +async function inviteRow(inviteId: string) { + const [row] = await app.container + .get(DRIZZLE) + .db.select() + .from(chatRoomInvite) + .where(eq(chatRoomInvite.id, inviteId)); + return row; +} + +async function watchRoomSignals(roomId: string) { + const transport = app.container.get(CHAT_REALTIME_TRANSPORT); + const probe = `probe-${randomUUID()}`; + const received: { name: string; payload: unknown }[] = []; + const unsubscribe = transport.subscribeSignal?.(chatChannel(roomId), (signal) => { + received.push(signal); + }); + await vi.waitFor( + async () => { + await transport.signal?.(chatChannel(roomId), probe, {}); + expect(received.some((signal) => signal.name === probe)).toBe(true); + }, + { timeout: 10_000, interval: 250 }, + ); + return { received, unsubscribe: () => unsubscribe?.() }; +} + +beforeAll(async () => { + process.env['BETTER_AUTH_SECRET'] ??= 'e2e-test-better-auth-secret-please-change-000000'; + process.env['AUTH_SECRET'] ??= process.env['BETTER_AUTH_SECRET']; + process.env['WITHDRAWAL_PIN_HMAC_SECRET'] ??= 'e2e-test-withdrawal-pin-hmac-secret-000000'; + process.env['NODE_ENV'] ??= 'test'; + + db = await setupTestDb(); + app = await bootTestApp({ plugins: await loadExtensions(), databaseUrl: db.url }); + await seedMinimal(app.container, { playerCount: 0 }); + admin = await asAdmin(app.app); +}, 60_000); + +afterAll(async () => { + await app?.close(); + await db?.dispose(); +}); + +describe('chat room invites: who may invite', () => { + it('lets the owner invite', async () => { + const owner = await registerChatter('host'); + const invitee = await registerChatter('guest'); + const room = await createRoom(owner); + + const sent = await sendInvite(owner, room, invitee); + + expect(sent).toMatchObject({ + roomId: room.id, + inviterId: owner.userId, + inviteeId: invitee.userId, + status: 'pending', + respondedAt: null, + }); + }); + + it('lets a moderator invite only when the room allows moderator invites', async () => { + const owner = await registerChatter('host'); + const room = await createRoom(owner); + const moderator = await addModerator(owner, room); + const invitee = await registerChatter('guest'); + + await expectRejected(await invite(moderator, room, invitee.userId), 403, 'forbidden'); + await configureRoom(owner, room, { moderatorInvite: true }); + await sendInvite(moderator, room, invitee); + }); + + it('refuses a plain member even when moderator invites are on', async () => { + const owner = await registerChatter('host'); + const member = await registerChatter('member'); + const invitee = await registerChatter('guest'); + const room = await createRoom(owner); + await joinByCode(member, room); + await configureRoom(owner, room, { moderatorInvite: true }); + + await expectRejected(await invite(member, room, invitee.userId), 403, 'forbidden'); + }); + + it('answers a non-member as if the room did not exist', async () => { + const owner = await registerChatter('host'); + const outsider = await registerChatter('outside'); + const invitee = await registerChatter('guest'); + const room = await createRoom(owner); + + expect((await invite(outsider, room, invitee.userId)).status).toBe(404); + expect( + (await outsider.client.get(`/chat/rooms/${room.id}/invite-candidates?q=gu`)).status, + ).toBe(404); + }); + + it('refuses an anonymous caller', async () => { + const owner = await registerChatter('host'); + const room = await createRoom(owner); + + const res = await app.app.request(`/chat/rooms/${room.id}/invites`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ userId: owner.userId }), + }); + + expect(res.status).toBe(401); + }); +}); + +describe('chat room invites: rejections', () => { + it('refuses inviting yourself', async () => { + const owner = await registerChatter('host'); + const room = await createRoom(owner); + + await expectRejected(await invite(owner, room, owner.userId), 400, 'self'); + }); + + it('refuses a staff account', async () => { + const owner = await registerChatter('host'); + const staff = await registerChatter('crew'); + const room = await createRoom(owner); + await app.container + .get(DRIZZLE) + .db.update(user) + .set({ role: 'admin' }) + .where(eq(user.id, staff.userId)); + + await expectRejected(await invite(owner, room, staff.userId), 400, 'not_player'); + }); + + it('refuses a player who blocked the inviter, without saying why', async () => { + const owner = await registerChatter('host'); + const blocker = await registerChatter('blocker'); + const room = await createRoom(owner); + expect((await blocker.client.post('/chat/blocks', { blockedId: owner.userId })).status).toBe( + 200, + ); + + await expectRejected(await invite(owner, room, blocker.userId), 403, 'unavailable'); + }); + + it('refuses a suspended player', async () => { + const owner = await registerChatter('host'); + const suspended = await registerChatter('susp'); + const room = await createRoom(owner); + await app.container + .get(DRIZZLE) + .db.update(player) + .set({ status: 'suspended' }) + .where(eq(player.userId, suspended.userId)); + + await expectRejected(await invite(owner, room, suspended.userId), 403, 'unavailable'); + }); + + it('refuses a player banned from the room', async () => { + const owner = await registerChatter('host'); + const banned = await registerChatter('banned'); + const room = await createRoom(owner); + await joinByCode(banned, room); + const ban = await owner.client.post(`/chat/rooms/${room.id}/ban`, { + userId: banned.userId, + reason: 'spam', + }); + expect(ban.status).toBe(200); + + await expectRejected(await invite(owner, room, banned.userId), 403, 'banned'); + }); + + it('refuses a player under a platform chat ban', async () => { + const owner = await registerChatter('host'); + const banned = await registerChatter('banned'); + const room = await createRoom(owner); + const ban = await admin.post('/backoffice/chat/bans', { + userId: banned.userId, + roomId: '__all', + reason: 'spam', + }); + expect(ban.status).toBe(200); + + await expectRejected(await invite(owner, room, banned.userId), 403, 'unavailable'); + }); + + it('refuses a player who is already a member', async () => { + const owner = await registerChatter('host'); + const member = await registerChatter('member'); + const room = await createRoom(owner); + await joinByCode(member, room); + + await expectRejected(await invite(owner, room, member.userId), 409, 'member'); + }); + + it('refuses a second pending invite for the same player', async () => { + const owner = await registerChatter('host'); + const invitee = await registerChatter('guest'); + const room = await createRoom(owner); + await sendInvite(owner, room, invitee); + + await expectRejected(await invite(owner, room, invitee.userId), 409, 'invited'); + }); + + it('refuses an inviter under a platform chat ban', async () => { + const owner = await registerChatter('host'); + const invitee = await registerChatter('guest'); + const room = await createRoom(owner); + const ban = await admin.post('/backoffice/chat/bans', { + userId: owner.userId, + roomId: '__all', + reason: 'spam', + }); + expect(ban.status).toBe(200); + + expect((await invite(owner, room, invitee.userId)).status).toBe(403); + expect(await myInvites(invitee)).toEqual([]); + }); + + it('refuses inviting into a locked room', async () => { + const owner = await registerChatter('host'); + const invitee = await registerChatter('guest'); + const room = await createRoom(owner); + await configureRoom(owner, room, { lockRoom: true }); + + await expectRejected(await invite(owner, room, invitee.userId), 403, 'locked'); + }); +}); + +describe('chat room invites: accepting', () => { + it('adds the member, signals the room, notifies the invitee and records the audit trail', async () => { + const owner = await registerChatter('host'); + const invitee = await registerChatter('guest'); + const room = await createRoom(owner); + const sent = await sendInvite(owner, room, invitee); + + await vi.waitFor(async () => { + const listRes = await invitee.client.get('/notifications'); + expect(listRes.status).toBe(200); + const { items } = (await listRes.json()) as { + items: { type: string; title: string; body: string; data: Record }[]; + }; + const row = items.find((n) => n.type === 'chat.room_invite.received'); + expect(row).toMatchObject({ + title: 'Room invite', + body: `${owner.username} invited you to ${room.name}`, + data: { inviteId: sent.id, roomId: room.id, inviterId: owner.userId }, + }); + }, WAIT); + + const listed = await myInvites(invitee); + expect(listed).toEqual([ + { + id: sent.id, + roomId: room.id, + roomName: room.name, + inviterId: owner.userId, + inviterUsername: owner.username, + createdAt: sent.createdAt, + }, + ]); + + const signals = await watchRoomSignals(room.id); + try { + const accepted = await invitee.client.post(`/chat/invites/${sent.id}/accept`); + expect(accepted.status).toBe(200); + expect(ChatRoomSchema.parse(await accepted.json()).id).toBe(room.id); + + await vi.waitFor(() => { + expect(signals.received).toContainEqual({ + name: CHAT_MEMBER_JOINED_SIGNAL, + payload: { roomId: room.id, userId: invitee.userId }, + }); + }, WAIT); + } finally { + signals.unsubscribe(); + } + + expect(await roomMemberIds(owner, room)).toContain(invitee.userId); + expect(await inviteRow(sent.id)).toMatchObject({ status: 'accepted' }); + expect(await myInvites(invitee)).toEqual([]); + const sentMessage = await invitee.client.post(`/chat/rooms/${room.id}/messages`, { + content: 'hello', + }); + expect(sentMessage.status).toBe(200); + + await vi.waitFor(async () => { + const auditRes = await admin.get( + `/audit/logs?resourceId=${sent.id}&action=chat.room.invite.accepted`, + ); + expect(auditRes.status).toBe(200); + const { items } = (await auditRes.json()) as { items: unknown[] }; + expect(items[0]).toMatchObject({ + actorType: 'player', + actorId: invitee.playerId, + resourceType: 'chat_room_invite', + }); + }, WAIT); + }); + + it('signals the room when a player joins by code', async () => { + const owner = await registerChatter('host'); + const member = await registerChatter('member'); + const room = await createRoom(owner); + + const signals = await watchRoomSignals(room.id); + try { + await joinByCode(member, room); + await vi.waitFor(() => { + expect(signals.received).toContainEqual({ + name: CHAT_MEMBER_JOINED_SIGNAL, + payload: { roomId: room.id, userId: member.userId }, + }); + }, WAIT); + } finally { + signals.unsubscribe(); + } + }); + + it('does not signal a staff account joining', async () => { + const owner = await registerChatter('host'); + const member = await registerChatter('member'); + const room = await createRoom(owner); + + const signals = await watchRoomSignals(room.id); + try { + expect((await admin.post(`/backoffice/chat/rooms/${room.id}/join`)).status).toBe(200); + await joinByCode(member, room); + await vi.waitFor(() => { + expect(signals.received).toContainEqual({ + name: CHAT_MEMBER_JOINED_SIGNAL, + payload: { roomId: room.id, userId: member.userId }, + }); + }, WAIT); + expect(signals.received.filter((s) => s.name === CHAT_MEMBER_JOINED_SIGNAL)).toHaveLength(1); + } finally { + signals.unsubscribe(); + } + }); + + it('refuses an invite addressed to someone else', async () => { + const owner = await registerChatter('host'); + const invitee = await registerChatter('guest'); + const other = await registerChatter('other'); + const room = await createRoom(owner); + const sent = await sendInvite(owner, room, invitee); + + expect((await other.client.post(`/chat/invites/${sent.id}/accept`)).status).toBe(404); + expect((await other.client.post(`/chat/invites/${sent.id}/decline`)).status).toBe(404); + expect(await inviteRow(sent.id)).toMatchObject({ status: 'pending' }); + }); + + it('refuses accepting once the room is locked', async () => { + const owner = await registerChatter('host'); + const invitee = await registerChatter('guest'); + const room = await createRoom(owner); + const sent = await sendInvite(owner, room, invitee); + await configureRoom(owner, room, { lockRoom: true }); + + await expectRejected( + await invitee.client.post(`/chat/invites/${sent.id}/accept`), + 403, + 'locked', + ); + expect(await roomMemberIds(owner, room)).not.toContain(invitee.userId); + }); + + it('refuses accepting after the invitee blocked the inviter', async () => { + const owner = await registerChatter('host'); + const invitee = await registerChatter('guest'); + const room = await createRoom(owner); + const sent = await sendInvite(owner, room, invitee); + expect((await invitee.client.post('/chat/blocks', { blockedId: owner.userId })).status).toBe( + 200, + ); + + await expectRejected( + await invitee.client.post(`/chat/invites/${sent.id}/accept`), + 403, + 'blocked', + ); + }); + + it('refuses accepting under a platform chat ban', async () => { + const owner = await registerChatter('host'); + const invitee = await registerChatter('guest'); + const room = await createRoom(owner); + const sent = await sendInvite(owner, room, invitee); + const ban = await admin.post('/backoffice/chat/bans', { + userId: invitee.userId, + roomId: '__all', + reason: 'spam', + }); + expect(ban.status).toBe(200); + + expect((await invitee.client.post(`/chat/invites/${sent.id}/accept`)).status).toBe(403); + expect(await roomMemberIds(owner, room)).not.toContain(invitee.userId); + }); +}); + +describe('chat room invites: declining and expiry', () => { + it('declines an invite and lets the owner invite again', async () => { + const owner = await registerChatter('host'); + const invitee = await registerChatter('guest'); + const room = await createRoom(owner); + const first = await sendInvite(owner, room, invitee); + + const declined = await invitee.client.post(`/chat/invites/${first.id}/decline`); + expect(declined.status).toBe(200); + expect(await declined.json()).toEqual({ success: true }); + expect(await inviteRow(first.id)).toMatchObject({ status: 'declined' }); + expect(await myInvites(invitee)).toEqual([]); + expect((await invitee.client.post(`/chat/invites/${first.id}/accept`)).status).toBe(404); + + const second = await sendInvite(owner, room, invitee); + expect(second.id).not.toBe(first.id); + expect((await myInvites(invitee)).map((row) => row.id)).toEqual([second.id]); + }); + + it("voids a moderator's invites once moderator invites are switched off", async () => { + const owner = await registerChatter('host'); + const invitee = await registerChatter('guest'); + const room = await createRoom(owner); + const moderator = await addModerator(owner, room); + await configureRoom(owner, room, { moderatorInvite: true }); + const sent = await sendInvite(moderator, room, invitee); + await configureRoom(owner, room, { moderatorInvite: false }); + + expect(await myInvites(invitee)).toEqual([]); + expect((await invitee.client.post(`/chat/invites/${sent.id}/accept`)).status).toBe(404); + expect(await roomMemberIds(owner, room)).not.toContain(invitee.userId); + await sendInvite(owner, room, invitee); + }); + + it('treats an invite older than seven days as gone and allows a fresh one', async () => { + const owner = await registerChatter('host'); + const invitee = await registerChatter('guest'); + const room = await createRoom(owner); + const stale = await sendInvite(owner, room, invitee); + await app.container + .get(DRIZZLE) + .db.update(chatRoomInvite) + .set({ createdAt: new Date(Date.now() - 8 * 24 * 60 * 60 * 1000) }) + .where(eq(chatRoomInvite.id, stale.id)); + + expect(await myInvites(invitee)).toEqual([]); + expect((await invitee.client.post(`/chat/invites/${stale.id}/accept`)).status).toBe(404); + expect((await invitee.client.post(`/chat/invites/${stale.id}/decline`)).status).toBe(404); + const statuses = await owner.client.post(`/chat/rooms/${room.id}/invite-statuses`, { + userIds: [invitee.userId], + }); + expect(ChatRoomInviteLookupSchema.array().parse(await statuses.json())).toEqual([ + { userId: invitee.userId, inviteStatus: 'available' }, + ]); + + const fresh = await sendInvite(owner, room, invitee); + expect((await myInvites(invitee)).map((row) => row.id)).toEqual([fresh.id]); + }); +}); + +describe('chat room invites: finding players', () => { + it('matches usernames only, hides blockers and reports each status', async () => { + const owner = await registerChatter('host'); + const room = await createRoom(owner); + const tag = `pk${randomInt(10 ** 4, 10 ** 5)}`; + const available = await registerChatter(tag); + const member = await registerChatter(tag); + const invited = await registerChatter(tag); + const blocker = await registerChatter(tag); + const emailOnly = await registerChatter('mailer', `${tag}-mail-${randomUUID()}@e2e.test`); + await joinByCode(member, room); + await sendInvite(owner, room, invited); + expect((await blocker.client.post('/chat/blocks', { blockedId: owner.userId })).status).toBe( + 200, + ); + + const res = await owner.client.get( + `/chat/rooms/${room.id}/invite-candidates?q=${tag.toUpperCase()}&limit=50`, + ); + expect(res.status).toBe(200); + const candidates = ChatRoomInviteCandidateSchema.array().parse(await res.json()); + const byId = new Map(candidates.map((row) => [row.userId, row.inviteStatus])); + + expect(byId.get(available.userId)).toBe('available'); + expect(byId.get(member.userId)).toBe('member'); + expect(byId.get(invited.userId)).toBe('invited'); + expect(byId.has(blocker.userId)).toBe(false); + expect(byId.has(emailOnly.userId)).toBe(false); + expect(byId.has(owner.userId)).toBe(false); + expect(candidates.find((row) => row.userId === available.userId)).toMatchObject({ + username: available.username, + avatarUrl: null, + }); + }); + + it('reports a blocker as unavailable in the status lookup', async () => { + const owner = await registerChatter('host'); + const room = await createRoom(owner); + const blocker = await registerChatter('blocker'); + const available = await registerChatter('avail'); + expect((await blocker.client.post('/chat/blocks', { blockedId: owner.userId })).status).toBe( + 200, + ); + + const res = await owner.client.post(`/chat/rooms/${room.id}/invite-statuses`, { + userIds: [blocker.userId, available.userId, randomUUID()], + }); + expect(res.status).toBe(200); + const rows = ChatRoomInviteLookupSchema.array().parse(await res.json()); + + expect(rows).toEqual([ + { userId: blocker.userId, inviteStatus: 'unavailable' }, + { userId: available.userId, inviteStatus: 'available' }, + { userId: expect.any(String), inviteStatus: 'unavailable' }, + ]); + }); + + it('keeps showing a member who blocked the inviter as a member', async () => { + const owner = await registerChatter('host'); + const room = await createRoom(owner); + const member = await registerChatter('member'); + await joinByCode(member, room); + expect((await member.client.post('/chat/blocks', { blockedId: owner.userId })).status).toBe( + 200, + ); + + await expectRejected(await invite(owner, room, member.userId), 409, 'member'); + const statuses = await owner.client.post(`/chat/rooms/${room.id}/invite-statuses`, { + userIds: [member.userId], + }); + expect(ChatRoomInviteLookupSchema.array().parse(await statuses.json())).toEqual([ + { userId: member.userId, inviteStatus: 'member' }, + ]); + const search = await owner.client.get( + `/chat/rooms/${room.id}/invite-candidates?q=${member.username}`, + ); + expect(ChatRoomInviteCandidateSchema.array().parse(await search.json())).toMatchObject([ + { userId: member.userId, inviteStatus: 'member' }, + ]); + }); + + it('reports a player under a platform chat ban as unavailable', async () => { + const owner = await registerChatter('host'); + const room = await createRoom(owner); + const banned = await registerChatter('banned'); + const ban = await admin.post('/backoffice/chat/bans', { + userId: banned.userId, + roomId: '__all', + reason: 'spam', + }); + expect(ban.status).toBe(200); + + const res = await owner.client.post(`/chat/rooms/${room.id}/invite-statuses`, { + userIds: [banned.userId], + }); + expect(ChatRoomInviteLookupSchema.array().parse(await res.json())).toEqual([ + { userId: banned.userId, inviteStatus: 'unavailable' }, + ]); + }); + + it('refuses the lookup to a plain member', async () => { + const owner = await registerChatter('host'); + const member = await registerChatter('member'); + const room = await createRoom(owner); + await joinByCode(member, room); + + const res = await member.client.post(`/chat/rooms/${room.id}/invite-statuses`, { + userIds: [owner.userId], + }); + + expect(res.status).toBe(403); + }); +}); + +describe('chat room invites: stored rows', () => { + it('keeps one pending row per room and invitee', async () => { + const owner = await registerChatter('host'); + const invitee = await registerChatter('guest'); + const room = await createRoom(owner); + const responses = await Promise.all([ + invite(owner, room, invitee.userId), + invite(owner, room, invitee.userId), + invite(owner, room, invitee.userId), + ]); + + expect(responses.map((res) => res.status).sort()).toEqual([200, 409, 409]); + + const rows = await app.container + .get(DRIZZLE) + .db.select() + .from(chatRoomInvite) + .where(and(eq(chatRoomInvite.roomId, room.id), eq(chatRoomInvite.status, 'pending'))); + expect(rows).toHaveLength(1); + }); +}); From 7a82eaa00f9e2a6ec2d8345b708314a935578089 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oleksandr=20Kapitu=C5=82a?= Date: Mon, 5 Oct 2026 12:47:48 +0200 Subject: [PATCH 2/5] fix(chat): harden room invite audit, liveness and notification text - Record a declined invite as a successful audit action. - Drop an invite once its inviter's account is closed or the inviter falls under a platform chat ban reaching the room, using the send-path scope rule. - Retire a lapsed invite with a terminal expired status instead of deleting it, and audit the lapse through chat.room.invite.expired. - Keep room-controlled text out of the invite notification body; the room name travels in data. - Require at least three characters for an invite candidate search. --- .../src/audit/__tests__/map-event.test.ts | 19 +++++ packages/core/src/audit/plugin.ts | 16 +++++ packages/core/src/contracts/schemas/events.ts | 7 ++ .../src/engagement/chat/contract/constants.ts | 2 +- .../src/engagement/chat/contract/index.ts | 2 +- .../migrations/0017_chat_room_invite.sql | 2 +- .../migrations/meta/0017_snapshot.json | 4 +- .../drizzle/migrations/meta/_journal.json | 2 +- .../chat/service/chat-room-invite.service.ts | 37 ++++++++-- .../src/engagement/notifications/plugin.ts | 4 +- .../__tests__/chat-room-invites.e2e.test.ts | 70 +++++++++++++++++-- 11 files changed, 147 insertions(+), 18 deletions(-) diff --git a/packages/core/src/audit/__tests__/map-event.test.ts b/packages/core/src/audit/__tests__/map-event.test.ts index cdda8c1d3..00bed79f1 100644 --- a/packages/core/src/audit/__tests__/map-event.test.ts +++ b/packages/core/src/audit/__tests__/map-event.test.ts @@ -1027,10 +1027,29 @@ describe('mapEventToRecord: chat.room.invite', () => { actorId: playerId, resourceType: 'chat_room_invite', resourceId: inviteId, + result: 'success', after: { roomId, inviterId, status: 'declined' }, }); }); + it('records a lapsed invite as a system expiry', async () => { + const row = await mapEventToRecord('chat.room.invite.expired', { + inviteId, + roomId, + inviterId, + inviteeId: userId, + }); + + expect(row).toMatchObject({ + actorType: 'system', + action: 'chat.room.invite.expired', + resourceType: 'chat_room_invite', + resourceId: inviteId, + result: 'success', + after: { roomId, inviterId, inviteeId: userId, status: 'expired' }, + }); + }); + it('falls back to the inviter user id when no player backs the inviter', async () => { const row = await mapEventToRecord('chat.room.invite.sent', { inviteId, diff --git a/packages/core/src/audit/plugin.ts b/packages/core/src/audit/plugin.ts index a140a1027..7109467b7 100644 --- a/packages/core/src/audit/plugin.ts +++ b/packages/core/src/audit/plugin.ts @@ -491,6 +491,7 @@ export async function mapEventToRecord( actorId: str(p['playerId']) ?? str(p['inviteeId']), resourceType: 'chat_room_invite', resourceId: str(p['inviteId']), + result: 'success', after: { roomId: str(p['roomId']), inviterId: str(p['inviterId']), @@ -499,6 +500,20 @@ export async function mapEventToRecord( }; } + if (topic === 'chat.room.invite.expired') { + return { + ...base, + resourceType: 'chat_room_invite', + resourceId: str(p['inviteId']), + after: { + roomId: str(p['roomId']), + inviterId: str(p['inviterId']), + inviteeId: str(p['inviteeId']), + status: 'expired', + }, + }; + } + // actorId = the granting/revoking owner's resolved playerId, falling back to the raw acting // user id when no player record backs them (the actor must never be lost on a permission // change). resource = the member whose role moved; before/after carry the two roles. @@ -1363,6 +1378,7 @@ const SUBSCRIBED_TOPICS: DomainEventName[] = [ 'chat.room.invite.sent', 'chat.room.invite.accepted', 'chat.room.invite.declined', + 'chat.room.invite.expired', 'chat.room.member.kicked', 'chat.room.member.banned', // chat.gift.sent diff --git a/packages/core/src/contracts/schemas/events.ts b/packages/core/src/contracts/schemas/events.ts index 8f1772c7d..34fe0dcc3 100644 --- a/packages/core/src/contracts/schemas/events.ts +++ b/packages/core/src/contracts/schemas/events.ts @@ -886,6 +886,13 @@ export const domainEventSchemas = { inviteeId: UuidSchema, playerId: UuidSchema.nullable(), }), + // A lapsed pending invite was retired when the same player was invited again. + 'chat.room.invite.expired': authContextBase.extend({ + inviteId: UuidSchema, + roomId: UuidSchema, + inviterId: UuidSchema, + inviteeId: UuidSchema, + }), 'chat.room.member.left': authContextBase.extend({ roomId: UuidSchema, userId: UuidSchema, diff --git a/packages/core/src/engagement/chat/contract/constants.ts b/packages/core/src/engagement/chat/contract/constants.ts index a35433171..5f819b0c6 100644 --- a/packages/core/src/engagement/chat/contract/constants.ts +++ b/packages/core/src/engagement/chat/contract/constants.ts @@ -30,7 +30,7 @@ export const CHAT_ROOM_SCHEDULED_FOR_DELETION_SIGNAL = 'chat:room-scheduled-for- export const OWNERLESS_ROOM_RETENTION_DAYS = 30; -export const CHAT_ROOM_INVITE_STATUSES = ['pending', 'accepted', 'declined'] as const; +export const CHAT_ROOM_INVITE_STATUSES = ['pending', 'accepted', 'declined', 'expired'] as const; export const CHAT_ROOM_INVITE_CANDIDATE_STATUSES = [ 'available', diff --git a/packages/core/src/engagement/chat/contract/index.ts b/packages/core/src/engagement/chat/contract/index.ts index 7e6b6f1dc..9868c3465 100644 --- a/packages/core/src/engagement/chat/contract/index.ts +++ b/packages/core/src/engagement/chat/contract/index.ts @@ -376,7 +376,7 @@ export type MyChatRoomInvite = z.infer; export const SearchRoomInviteCandidatesInputSchema = z.object({ roomId: UuidSchema, - q: z.string().trim().min(1).max(ROOM_INVITE_SEARCH_MAX_LENGTH), + q: z.string().trim().min(3).max(ROOM_INVITE_SEARCH_MAX_LENGTH), limit: z.coerce.number().int().min(1).max(ROOM_INVITE_CANDIDATE_LIMIT_MAX).default(20), }); diff --git a/packages/core/src/engagement/chat/drizzle/migrations/0017_chat_room_invite.sql b/packages/core/src/engagement/chat/drizzle/migrations/0017_chat_room_invite.sql index 729bed125..9797865b8 100644 --- a/packages/core/src/engagement/chat/drizzle/migrations/0017_chat_room_invite.sql +++ b/packages/core/src/engagement/chat/drizzle/migrations/0017_chat_room_invite.sql @@ -1,4 +1,4 @@ -CREATE TYPE "public"."chat_room_invite_status" AS ENUM('pending', 'accepted', 'declined');--> statement-breakpoint +CREATE TYPE "public"."chat_room_invite_status" AS ENUM('pending', 'accepted', 'declined', 'expired');--> statement-breakpoint CREATE TABLE "chat_room_invite" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "room_id" uuid NOT NULL, diff --git a/packages/core/src/engagement/chat/drizzle/migrations/meta/0017_snapshot.json b/packages/core/src/engagement/chat/drizzle/migrations/meta/0017_snapshot.json index 8fbfbb321..33b9323c5 100644 --- a/packages/core/src/engagement/chat/drizzle/migrations/meta/0017_snapshot.json +++ b/packages/core/src/engagement/chat/drizzle/migrations/meta/0017_snapshot.json @@ -1,5 +1,5 @@ { - "id": "c8f83bfe-3a79-4711-acbb-da9300d91b1f", + "id": "a4336416-e71a-4d7c-9637-1e20b8cd04c3", "prevId": "550e8414-4ccc-4253-b985-e957c81c4511", "version": "7", "dialect": "postgresql", @@ -1706,7 +1706,7 @@ "public.chat_room_invite_status": { "name": "chat_room_invite_status", "schema": "public", - "values": ["pending", "accepted", "declined"] + "values": ["pending", "accepted", "declined", "expired"] }, "public.chat_room_role": { "name": "chat_room_role", diff --git a/packages/core/src/engagement/chat/drizzle/migrations/meta/_journal.json b/packages/core/src/engagement/chat/drizzle/migrations/meta/_journal.json index 7fdfd85a4..de98bb89d 100644 --- a/packages/core/src/engagement/chat/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/engagement/chat/drizzle/migrations/meta/_journal.json @@ -124,7 +124,7 @@ { "idx": 17, "version": "7", - "when": 1791064264513, + "when": 1791196667898, "tag": "0017_chat_room_invite", "breakpoints": true } diff --git a/packages/core/src/engagement/chat/service/chat-room-invite.service.ts b/packages/core/src/engagement/chat/service/chat-room-invite.service.ts index 70ab72c63..755efa222 100644 --- a/packages/core/src/engagement/chat/service/chat-room-invite.service.ts +++ b/packages/core/src/engagement/chat/service/chat-room-invite.service.ts @@ -86,10 +86,22 @@ function liveInvite(db: Db, now: Date) { and( eq(chatRoomMember.roomId, chatRoomInvite.roomId), eq(chatRoomMember.userId, chatRoomInvite.inviterId), + isNull(chatRoomMember.accountClosedAt), invitingRole, ), ), ), + notExists( + db + .select({ id: chatPlatformBan.id }) + .from(chatPlatformBan) + .where( + and( + eq(chatPlatformBan.userId, chatRoomInvite.inviterId), + activePlatformBan(chatRoomInvite.roomId, now), + ), + ), + ), ); } @@ -101,7 +113,7 @@ function activeRoomBan(roomId: Uuid, now: Date) { ); } -function activePlatformBan(roomId: Uuid, now: Date) { +function activePlatformBan(roomId: Uuid | typeof chatRoomInvite.roomId, now: Date) { return and( isNull(chatPlatformBan.liftedAt), or(isNull(chatPlatformBan.expiresAt), gt(chatPlatformBan.expiresAt, now)), @@ -109,6 +121,7 @@ function activePlatformBan(roomId: Uuid, now: Date) { inArray(chatPlatformBan.scope, platformScopesFor('private')), and(eq(chatPlatformBan.scope, 'room'), eq(chatPlatformBan.roomId, roomId)), ), + or(isNull(chatPlatformBan.roomId), eq(chatPlatformBan.roomId, roomId)), ); } @@ -267,7 +280,7 @@ export class ChatRoomInviteService { if (actorId === userId) { throw new ChatRoomInviteSelfError(); } - const { invite, room } = await this.drizzle.db.transaction((t) => + const { invite, room, lapsed } = await this.drizzle.db.transaction((t) => withAdvisoryXactLock(t, `chat-room:${roomId}`, async () => { const now = new Date(); const room = await this.assertCanInvite(t, actorId, roomId); @@ -311,15 +324,17 @@ export class ChatRoomInviteService { throw new ChatRoomInviteeUnavailableError(userId); } // A lapsed invite still holds the one-pending slot in the partial unique index. - await t - .delete(chatRoomInvite) + const lapsed = await t + .update(chatRoomInvite) + .set({ status: 'expired' }) .where( and( eq(chatRoomInvite.roomId, roomId), eq(chatRoomInvite.inviteeId, userId), eq(chatRoomInvite.status, 'pending'), ), - ); + ) + .returning({ id: chatRoomInvite.id, inviterId: chatRoomInvite.inviterId }); const [invite] = await t .insert(chatRoomInvite) .values({ roomId, inviterId: actorId, inviteeId: userId }) @@ -327,9 +342,19 @@ export class ChatRoomInviteService { if (!invite) { throw new Error('chat room invite insert returned no row'); } - return { invite, room }; + return { invite, room, lapsed }; }), ); + for (const expired of lapsed) { + this.events.emit('chat.room.invite.expired', { + inviteId: expired.id, + roomId, + inviterId: expired.inviterId, + inviteeId: userId, + ip: ip ?? null, + userAgent: userAgent ?? null, + }); + } this.events.emit('chat.room.invite.sent', { inviteId: invite.id, roomId, diff --git a/packages/core/src/engagement/notifications/plugin.ts b/packages/core/src/engagement/notifications/plugin.ts index 274b1151a..0448d2a66 100644 --- a/packages/core/src/engagement/notifications/plugin.ts +++ b/packages/core/src/engagement/notifications/plugin.ts @@ -382,8 +382,8 @@ export const notificationEventMap: NotificationMapEntry[] = [ userId: p.inviteeId, type: 'chat.room_invite.received', title: 'Room invite', - body: `${p.inviterUsername} invited you to ${p.roomName}`, - data: { inviteId: p.inviteId, roomId: p.roomId, inviterId: p.inviterId }, + body: `${p.inviterUsername} invited you to a private room`, + data: { inviteId: p.inviteId, roomId: p.roomId, inviterId: p.inviterId, roomName: p.roomName }, })), // Pre-existing types: in-app only, unchanged from before the declarative-map refactor. diff --git a/packages/testing/src/__tests__/chat-room-invites.e2e.test.ts b/packages/testing/src/__tests__/chat-room-invites.e2e.test.ts index d6240d341..e4ecba556 100644 --- a/packages/testing/src/__tests__/chat-room-invites.e2e.test.ts +++ b/packages/testing/src/__tests__/chat-room-invites.e2e.test.ts @@ -5,7 +5,7 @@ import { DRIZZLE, loadExtensions } from '@openora/core/server'; import { CHAT_REALTIME_TRANSPORT, chatChannel } from '@openora/core/contracts'; import { user } from '@openora/core/pam/schema/identity'; import { player } from '@openora/core/pam/schema/profile'; -import { chatRoomInvite } from '@openora/core/engagement/schema/chat'; +import { chatRoomInvite, chatRoomMember } from '@openora/core/engagement/schema/chat'; import { CHAT_MEMBER_JOINED_SIGNAL, ChatRoomInviteCandidateSchema, @@ -195,7 +195,7 @@ describe('chat room invites: who may invite', () => { expect((await invite(outsider, room, invitee.userId)).status).toBe(404); expect( - (await outsider.client.get(`/chat/rooms/${room.id}/invite-candidates?q=gu`)).status, + (await outsider.client.get(`/chat/rooms/${room.id}/invite-candidates?q=gue`)).status, ).toBe(404); }); @@ -345,8 +345,13 @@ describe('chat room invites: accepting', () => { const row = items.find((n) => n.type === 'chat.room_invite.received'); expect(row).toMatchObject({ title: 'Room invite', - body: `${owner.username} invited you to ${room.name}`, - data: { inviteId: sent.id, roomId: room.id, inviterId: owner.userId }, + body: `${owner.username} invited you to a private room`, + data: { + inviteId: sent.id, + roomId: room.id, + inviterId: owner.userId, + roomName: room.name, + }, }); }, WAIT); @@ -557,6 +562,54 @@ describe('chat room invites: declining and expiry', () => { const fresh = await sendInvite(owner, room, invitee); expect((await myInvites(invitee)).map((row) => row.id)).toEqual([fresh.id]); + expect(await inviteRow(stale.id)).toMatchObject({ status: 'expired', respondedAt: null }); + + await vi.waitFor(async () => { + const auditRes = await admin.get( + `/audit/logs?resourceId=${stale.id}&action=chat.room.invite.expired`, + ); + expect(auditRes.status).toBe(200); + const { items } = (await auditRes.json()) as { items: unknown[] }; + expect(items[0]).toMatchObject({ + actorType: 'system', + resourceType: 'chat_room_invite', + result: 'success', + }); + }, WAIT); + }); + + it("voids an invite once the inviting moderator's account is closed", async () => { + const owner = await registerChatter('host'); + const invitee = await registerChatter('guest'); + const room = await createRoom(owner); + const moderator = await addModerator(owner, room); + await configureRoom(owner, room, { moderatorInvite: true }); + const sent = await sendInvite(moderator, room, invitee); + await app.container + .get(DRIZZLE) + .db.update(chatRoomMember) + .set({ accountClosedAt: new Date() }) + .where(and(eq(chatRoomMember.roomId, room.id), eq(chatRoomMember.userId, moderator.userId))); + + expect(await myInvites(invitee)).toEqual([]); + expect((await invitee.client.post(`/chat/invites/${sent.id}/accept`)).status).toBe(404); + expect(await roomMemberIds(owner, room)).not.toContain(invitee.userId); + }); + + it('voids an invite once the inviter is put under a platform chat ban', async () => { + const owner = await registerChatter('host'); + const invitee = await registerChatter('guest'); + const room = await createRoom(owner); + const sent = await sendInvite(owner, room, invitee); + const ban = await admin.post('/backoffice/chat/bans', { + userId: owner.userId, + roomId: '__all', + reason: 'spam', + }); + expect(ban.status).toBe(200); + + expect(await myInvites(invitee)).toEqual([]); + expect((await invitee.client.post(`/chat/invites/${sent.id}/accept`)).status).toBe(404); }); }); @@ -660,6 +713,15 @@ describe('chat room invites: finding players', () => { ]); }); + it('refuses a search shorter than three characters', async () => { + const owner = await registerChatter('host'); + const room = await createRoom(owner); + + const res = await owner.client.get(`/chat/rooms/${room.id}/invite-candidates?q=ab`); + + expect(res.status).toBe(400); + }); + it('refuses the lookup to a plain member', async () => { const owner = await registerChatter('host'); const member = await registerChatter('member'); From a6b064a1a2acd6aef3648acea5e6ab7ff1abbbe9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oleksandr=20Kapitu=C5=82a?= Date: Mon, 5 Oct 2026 14:09:26 +0200 Subject: [PATCH 3/5] fix(chat): take the room lock when changing room settings A settings change could race an invite or accept under the separate configuration lock; it now serialises on the same per-room lock. --- packages/core/src/engagement/chat/service/chat.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/engagement/chat/service/chat.service.ts b/packages/core/src/engagement/chat/service/chat.service.ts index 47adf9192..d0205fcee 100644 --- a/packages/core/src/engagement/chat/service/chat.service.ts +++ b/packages/core/src/engagement/chat/service/chat.service.ts @@ -1051,7 +1051,7 @@ export class ChatService { } & ClientMeta) { await this.assertRoomModerator(roomId, actorId); const config = await this.drizzle.db.transaction((tx) => - withAdvisoryXactLock(tx, `chat-room-configuration:${roomId}`, async () => { + withAdvisoryXactLock(tx, `chat-room:${roomId}`, async () => { const before = await this.findRoomConfiguration(tx, roomId); const [after] = await tx .insert(chatRoomConfiguration) From 73dd080fce777126b061c75868a95fa667382263 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oleksandr=20Kapitu=C5=82a?= Date: Mon, 5 Oct 2026 14:09:29 +0200 Subject: [PATCH 4/5] fix(chat): audit refused invites and keep excluded players uninvitable - Emit identity.user.unauthorized_access when an invite, candidate search or status lookup is refused for lack of the invite right. - Report players under a responsible-gambling restriction as unavailable, read through PLAY_ELIGIBILITY, and refuse inviting them. - Stop revealing blocks in search and lookup; the invite itself still refuses a blocker as unavailable. - Compute candidate status in one query instead of parallel queries on one connection. - Fail the lookup limiter closed, export the search minimum length, signal joins only for private rooms, and make a retried accept return the joined room. --- .../src/engagement/chat/contract/constants.ts | 4 +- .../src/engagement/chat/contract/index.ts | 3 +- packages/core/src/engagement/chat/plugin.ts | 2 + .../core/src/engagement/chat/router/index.ts | 11 +- .../chat/service/chat-room-invite.service.ts | 288 ++++++++++-------- .../service/chat-room-membership.service.ts | 15 +- .../__tests__/chat-room-invites.e2e.test.ts | 132 +++++++- 7 files changed, 325 insertions(+), 130 deletions(-) diff --git a/packages/core/src/engagement/chat/contract/constants.ts b/packages/core/src/engagement/chat/contract/constants.ts index 5f819b0c6..e68be60ef 100644 --- a/packages/core/src/engagement/chat/contract/constants.ts +++ b/packages/core/src/engagement/chat/contract/constants.ts @@ -39,7 +39,7 @@ export const CHAT_ROOM_INVITE_CANDIDATE_STATUSES = [ 'banned', ] as const; -// `unavailable` hides why a player cannot be invited, a block by them included. +// `unavailable` hides why a player cannot be invited. export const CHAT_ROOM_INVITE_LOOKUP_STATUSES = [ ...CHAT_ROOM_INVITE_CANDIDATE_STATUSES, 'unavailable', @@ -51,4 +51,6 @@ export const ROOM_INVITE_STATUS_LOOKUP_MAX = 100; export const ROOM_INVITE_CANDIDATE_LIMIT_MAX = 50; +export const ROOM_INVITE_SEARCH_MIN_LENGTH = 3; + export const ROOM_INVITE_SEARCH_MAX_LENGTH = 50; diff --git a/packages/core/src/engagement/chat/contract/index.ts b/packages/core/src/engagement/chat/contract/index.ts index 9868c3465..8d81bc847 100644 --- a/packages/core/src/engagement/chat/contract/index.ts +++ b/packages/core/src/engagement/chat/contract/index.ts @@ -26,6 +26,7 @@ import { CHAT_ROOM_INVITE_LOOKUP_STATUSES, ROOM_INVITE_STATUS_LOOKUP_MAX, ROOM_INVITE_CANDIDATE_LIMIT_MAX, + ROOM_INVITE_SEARCH_MIN_LENGTH, ROOM_INVITE_SEARCH_MAX_LENGTH, } from './constants.js'; @@ -376,7 +377,7 @@ export type MyChatRoomInvite = z.infer; export const SearchRoomInviteCandidatesInputSchema = z.object({ roomId: UuidSchema, - q: z.string().trim().min(3).max(ROOM_INVITE_SEARCH_MAX_LENGTH), + q: z.string().trim().min(ROOM_INVITE_SEARCH_MIN_LENGTH).max(ROOM_INVITE_SEARCH_MAX_LENGTH), limit: z.coerce.number().int().min(1).max(ROOM_INVITE_CANDIDATE_LIMIT_MAX).default(20), }); diff --git a/packages/core/src/engagement/chat/plugin.ts b/packages/core/src/engagement/chat/plugin.ts index 6e2688b4d..9da9ce6bb 100644 --- a/packages/core/src/engagement/chat/plugin.ts +++ b/packages/core/src/engagement/chat/plugin.ts @@ -20,6 +20,7 @@ import { AUDIT_WRITER, IDENTITY_READER, PLATFORM_CONFIG, + PLAY_ELIGIBILITY, CHAT_MODERATION_EXPIRY_DEFAULT_CRON, UuidSchema, } from '@openora/core/contracts'; @@ -326,6 +327,7 @@ export default { c.get(EVENT_BUS), c.get(IDENTITY_READER), membershipService, + c.has(PLAY_ELIGIBILITY) ? c.get(PLAY_ELIGIBILITY) : undefined, ), roomBanService: new ChatRoomBanService( c.get(DRIZZLE), diff --git a/packages/core/src/engagement/chat/router/index.ts b/packages/core/src/engagement/chat/router/index.ts index 2ec065500..d30ebaae2 100644 --- a/packages/core/src/engagement/chat/router/index.ts +++ b/packages/core/src/engagement/chat/router/index.ts @@ -84,7 +84,7 @@ const ROOM_INVITE_RATE_LIMIT = { const ROOM_INVITE_LOOKUP_RATE_LIMIT = { limit: 120, windowMs: 60 * 1_000, - onUnavailable: 'allow', + onUnavailable: 'deny', } as const; const SEND_MESSAGE_RATE_LIMIT = { @@ -476,7 +476,12 @@ export function createChatRouter({ ); return mapErrors( { NOT_FOUND: ChatRoomNotFoundError, FORBIDDEN: ChatRoomInviteForbiddenError }, - () => inviteService.searchRoomInviteCandidates({ actorId, ...input }), + () => + inviteService.searchRoomInviteCandidates({ + actorId, + ...input, + ...context.clientMeta, + }), ); }, ), @@ -490,7 +495,7 @@ export function createChatRouter({ ); return mapErrors( { NOT_FOUND: ChatRoomNotFoundError, FORBIDDEN: ChatRoomInviteForbiddenError }, - () => inviteService.getRoomInviteStatuses({ actorId, ...input }), + () => inviteService.getRoomInviteStatuses({ actorId, ...input, ...context.clientMeta }), ); }), diff --git a/packages/core/src/engagement/chat/service/chat-room-invite.service.ts b/packages/core/src/engagement/chat/service/chat-room-invite.service.ts index 755efa222..62b72c64a 100644 --- a/packages/core/src/engagement/chat/service/chat-room-invite.service.ts +++ b/packages/core/src/engagement/chat/service/chat-room-invite.service.ts @@ -18,11 +18,17 @@ import { DrizzleService, likeContains, likePrefix, + mapConcurrent, serializeRow, withAdvisoryXactLock, } from '@openora/core/server'; import type { DrizzleDb, DrizzleTx, EventBus } from '@openora/core/server'; -import type { ClientMeta, IdentityReader, Uuid } from '@openora/core/contracts'; +import type { + ClientMeta, + IdentityReader, + PlayEligibilityPort, + Uuid, +} from '@openora/core/contracts'; import { user } from '@openora/core/pam/schema/identity'; import { player } from '@openora/core/pam/schema/profile'; import { @@ -63,7 +69,13 @@ const DAY_MS = 24 * 60 * 60 * 1000; const MY_INVITES_LIMIT = 100; -const UNAVAILABLE_PLAYER_STATUSES = ['suspended', 'closed'] as const; +const UNAVAILABLE_PLAYER_STATUSES = ['self_excluded', 'suspended', 'closed'] as const; + +const ELIGIBILITY_CHECK_CONCURRENCY = 8; + +const INVITE_AUDIT_RESOURCE = 'chat.room_invite'; + +type InviteAction = 'create' | 'search' | 'lookup'; type Db = DrizzleDb | DrizzleTx; @@ -125,7 +137,7 @@ function activePlatformBan(roomId: Uuid | typeof chatRoomInvite.roomId, now: Dat ); } -function activeBlock(blockerId: Uuid | typeof user.id, blockedId: Uuid) { +function activeBlock(blockerId: Uuid, blockedId: Uuid) { return and( eq(chatUserBlock.blockerId, blockerId), eq(chatUserBlock.blockedId, blockedId), @@ -142,43 +154,51 @@ function aliveInvitableRoom(roomId: Uuid) { ); } -// Blocks, platform bans and account state only hide a player who would otherwise read as +function memberRow(db: Db, roomId: Uuid) { + return db + .select({ id: chatRoomMember.id }) + .from(chatRoomMember) + .where(and(eq(chatRoomMember.roomId, roomId), eq(chatRoomMember.userId, user.id))); +} + +function roomBanRow(db: Db, roomId: Uuid, now: Date) { + return db + .select({ id: chatRoomBan.id }) + .from(chatRoomBan) + .where(and(activeRoomBan(roomId, now), eq(chatRoomBan.userId, user.id))); +} + +function liveInviteRow(db: Db, roomId: Uuid, now: Date) { + return db + .select({ id: chatRoomInvite.id }) + .from(chatRoomInvite) + .where( + and( + eq(chatRoomInvite.roomId, roomId), + eq(chatRoomInvite.inviteeId, user.id), + liveInvite(db, now), + ), + ); +} + +function inviteStatus(db: Db, roomId: Uuid, now: Date) { + return sql`case + when ${exists(memberRow(db, roomId))} then 'member' + when ${exists(roomBanRow(db, roomId, now))} then 'banned' + when ${exists(liveInviteRow(db, roomId, now))} then 'invited' + else 'available' end`; +} + +// Platform bans and account state only hide a player who would otherwise read as // available; a member, room-banned or invited player keeps that status, so nothing leaks. -function visibleCandidate(db: Db, actorId: Uuid, roomId: Uuid, now: Date) { +function visibleCandidate(db: Db, roomId: Uuid, now: Date) { const knownToRoom = or( - exists( - db - .select({ id: chatRoomMember.id }) - .from(chatRoomMember) - .where(and(eq(chatRoomMember.roomId, roomId), eq(chatRoomMember.userId, user.id))), - ), - exists( - db - .select({ id: chatRoomBan.id }) - .from(chatRoomBan) - .where(and(activeRoomBan(roomId, now), eq(chatRoomBan.userId, user.id))), - ), - exists( - db - .select({ id: chatRoomInvite.id }) - .from(chatRoomInvite) - .where( - and( - eq(chatRoomInvite.roomId, roomId), - eq(chatRoomInvite.inviteeId, user.id), - liveInvite(db, now), - ), - ), - ), + exists(memberRow(db, roomId)), + exists(roomBanRow(db, roomId, now)), + exists(liveInviteRow(db, roomId, now)), ); const unmasked = and( notInArray(player.status, [...UNAVAILABLE_PLAYER_STATUSES]), - notExists( - db - .select({ blockerId: chatUserBlock.blockerId }) - .from(chatUserBlock) - .where(activeBlock(user.id, actorId)), - ), notExists( db .select({ id: chatPlatformBan.id }) @@ -194,11 +214,18 @@ export class ChatRoomInviteService { private readonly drizzle: DrizzleService, private readonly events: EventBus, private readonly identityReader: IdentityReader, - private readonly membership: Pick, + private readonly membership: Pick, + private readonly playEligibility?: PlayEligibilityPort, ) {} // A non-member gets NOT_FOUND so a private room's existence is not revealed. - private async assertCanInvite(db: Db, actorId: Uuid, roomId: Uuid) { + private async assertCanInvite( + db: Db, + actorId: Uuid, + roomId: Uuid, + action: InviteAction, + meta: ClientMeta, + ) { const [room] = await db .select({ name: chatRoom.name, @@ -215,11 +242,9 @@ export class ChatRoomInviteService { .leftJoin(chatRoomConfiguration, eq(chatRoomConfiguration.roomId, chatRoom.id)) .where(aliveInvitableRoom(roomId)) .limit(1); - if (!room) { - throw new ChatRoomNotFoundError(roomId); - } - if (!room.canInvite) { - throw new ChatRoomInviteForbiddenError(roomId); + if (!room?.canInvite) { + await this.auditRefusal(db, actorId, action, meta); + throw room ? new ChatRoomInviteForbiddenError(roomId) : new ChatRoomNotFoundError(roomId); } return { name: room.name, @@ -228,46 +253,39 @@ export class ChatRoomInviteService { }; } - private async statusesFor( + // This check rejects before any shared guard runs, so it owes the audit log AdminGuard's signal. + private async auditRefusal( db: Db, - roomId: Uuid, - userIds: Uuid[], - ): Promise> { - const now = new Date(); - const [members, roomBans, pending] = await Promise.all([ - db - .select({ userId: chatRoomMember.userId }) - .from(chatRoomMember) - .where(and(eq(chatRoomMember.roomId, roomId), inArray(chatRoomMember.userId, userIds))), - db - .select({ userId: chatRoomBan.userId }) - .from(chatRoomBan) - .where(and(activeRoomBan(roomId, now), inArray(chatRoomBan.userId, userIds))), - db - .select({ userId: chatRoomInvite.inviteeId }) - .from(chatRoomInvite) - .where( - and( - eq(chatRoomInvite.roomId, roomId), - inArray(chatRoomInvite.inviteeId, userIds), - liveInvite(db, now), - ), - ), - ]); - const memberIds = new Set(members.map((row) => row.userId)); - const bannedIds = new Set(roomBans.map((row) => row.userId)); - const invitedIds = new Set(pending.map((row) => row.userId)); - return new Map( - userIds.map((id) => { - if (memberIds.has(id)) { - return [id, 'member']; - } - if (bannedIds.has(id)) { - return [id, 'banned']; - } - return [id, invitedIds.has(id) ? 'invited' : 'available']; - }), + actorId: Uuid, + action: InviteAction, + { ip, userAgent }: ClientMeta, + ) { + const [caller] = await db + .select({ role: user.role, playerId: player.id }) + .from(user) + .leftJoin(player, eq(player.userId, user.id)) + .where(eq(user.id, actorId)) + .limit(1); + this.events.emit('identity.user.unauthorized_access', { + userId: actorId, + playerId: caller?.playerId ?? null, + resource: INVITE_AUDIT_RESOURCE, + action, + ...(caller?.role ? { role: caller.role } : {}), + ip: ip ?? null, + userAgent: userAgent ?? null, + }); + } + + private async restrictedAmong(userIds: Uuid[]) { + const eligibility = this.playEligibility; + if (!eligibility || userIds.length === 0) { + return new Set(); + } + const restricted = await mapConcurrent(userIds, ELIGIBILITY_CHECK_CONCURRENCY, (id) => + eligibility.isRestricted(id), ); + return new Set(userIds.filter((_, index) => restricted[index])); } async inviteToRoom({ @@ -283,7 +301,7 @@ export class ChatRoomInviteService { const { invite, room, lapsed } = await this.drizzle.db.transaction((t) => withAdvisoryXactLock(t, `chat-room:${roomId}`, async () => { const now = new Date(); - const room = await this.assertCanInvite(t, actorId, roomId); + const room = await this.assertCanInvite(t, actorId, roomId, 'create', { ip, userAgent }); if (room.lockRoom) { throw new ChatRoomLockedError(roomId); } @@ -296,7 +314,12 @@ export class ChatRoomInviteService { throw new ChatRoomBannedError(roomId); } const [target] = await t - .select({ role: user.role, status: player.status }) + .select({ + role: user.role, + status: player.status, + inviteStatus: inviteStatus(t, roomId, now), + visible: sql`coalesce(${visibleCandidate(t, roomId, now)}, false)`, + }) .from(user) .leftJoin(player, eq(player.userId, user.id)) .where(eq(user.id, userId)) @@ -304,23 +327,21 @@ export class ChatRoomInviteService { if (!target || target.role !== 'player' || target.status === null) { throw new ChatRoomInviteeNotPlayerError(userId); } - const status = (await this.statusesFor(t, roomId, [userId])).get(userId); - if (status === 'member') { + if (target.inviteStatus === 'member') { throw new ChatRoomInviteeAlreadyMemberError(userId); } - if (status === 'banned') { + if (target.inviteStatus === 'banned') { throw new ChatRoomInviteeBannedError(userId); } - if (status === 'invited') { + if (target.inviteStatus === 'invited') { throw new ChatRoomInvitePendingError(userId); } - const [visible] = await t - .select({ id: user.id }) - .from(user) - .innerJoin(player, eq(player.userId, user.id)) - .where(and(eq(user.id, userId), visibleCandidate(t, actorId, roomId, now))) + const [block] = await t + .select({ blockerId: chatUserBlock.blockerId }) + .from(chatUserBlock) + .where(activeBlock(userId, actorId)) .limit(1); - if (!visible) { + if (!target.visible || block || (await this.restrictedAmong([userId])).size > 0) { throw new ChatRoomInviteeUnavailableError(userId); } // A lapsed invite still holds the one-pending slot in the partial unique index. @@ -374,23 +395,31 @@ export class ChatRoomInviteService { roomId, q, limit, + ip, + userAgent, }: { actorId: Uuid; roomId: Uuid; q: string; limit: number; - }): Promise { + } & ClientMeta): Promise { const db = this.drizzle.db; - await this.assertCanInvite(db, actorId, roomId); + await this.assertCanInvite(db, actorId, roomId, 'search', { ip, userAgent }); + const now = new Date(); const rows = await db - .select({ userId: user.id, username: user.username, avatarUrl: user.image }) + .select({ + userId: user.id, + username: user.username, + avatarUrl: user.image, + inviteStatus: inviteStatus(db, roomId, now), + }) .from(user) .innerJoin(player, eq(player.userId, user.id)) .where( and( ne(user.id, actorId), ilike(user.username, likeContains(q)), - visibleCandidate(db, actorId, roomId, new Date()), + visibleCandidate(db, roomId, now), ), ) .orderBy( @@ -398,42 +427,41 @@ export class ChatRoomInviteService { asc(user.username), ) .limit(limit); - if (rows.length === 0) { - return []; - } - const statuses = await this.statusesFor( - db, - roomId, - rows.map((row) => row.userId), + const restricted = await this.restrictedAmong( + rows.filter((row) => row.inviteStatus === 'available').map((row) => row.userId), ); - return rows.map((row) => ({ ...row, inviteStatus: statuses.get(row.userId) ?? 'available' })); + return rows.filter((row) => !restricted.has(row.userId)); } async getRoomInviteStatuses({ actorId, roomId, userIds, + ip, + userAgent, }: { actorId: Uuid; roomId: Uuid; userIds: Uuid[]; - }): Promise { + } & ClientMeta): Promise { const db = this.drizzle.db; - await this.assertCanInvite(db, actorId, roomId); + await this.assertCanInvite(db, actorId, roomId, 'lookup', { ip, userAgent }); const ids = [...new Set(userIds)]; - const [visible, statuses] = await Promise.all([ - db - .select({ userId: user.id }) - .from(user) - .innerJoin(player, eq(player.userId, user.id)) - .where(and(inArray(user.id, ids), visibleCandidate(db, actorId, roomId, new Date()))), - this.statusesFor(db, roomId, ids), - ]); - const visibleIds = new Set(visible.map((row) => row.userId)); - return ids.map((id) => ({ - userId: id, - inviteStatus: visibleIds.has(id) ? (statuses.get(id) ?? 'available') : 'unavailable', - })); + const now = new Date(); + const rows = await db + .select({ userId: user.id, inviteStatus: inviteStatus(db, roomId, now) }) + .from(user) + .innerJoin(player, eq(player.userId, user.id)) + .where(and(inArray(user.id, ids), visibleCandidate(db, roomId, now))); + const restricted = await this.restrictedAmong( + rows.filter((row) => row.inviteStatus === 'available').map((row) => row.userId), + ); + const statuses = new Map( + rows + .filter((row) => !restricted.has(row.userId)) + .map((row) => [row.userId, row.inviteStatus]), + ); + return ids.map((id) => ({ userId: id, inviteStatus: statuses.get(id) ?? 'unavailable' })); } async listMyRoomInvites(userId: Uuid) { @@ -498,7 +526,11 @@ export class ChatRoomInviteService { .where(addressedToCaller(this.drizzle.db, new Date())) .limit(1); if (!invite) { - throw new ChatRoomInviteNotFoundError(inviteId); + const room = await this.acceptedRoom(userId, inviteId); + if (!room) { + throw new ChatRoomInviteNotFoundError(inviteId); + } + return room; } const room = await this.membership.joinByInvite({ roomId: invite.roomId, @@ -550,6 +582,22 @@ export class ChatRoomInviteService { return room; } + // A retried accept returns the room it already joined, while the invitee is still in it. + private async acceptedRoom(userId: Uuid, inviteId: Uuid) { + const [accepted] = await this.drizzle.db + .select({ roomId: chatRoomInvite.roomId }) + .from(chatRoomInvite) + .where( + and( + eq(chatRoomInvite.id, inviteId), + eq(chatRoomInvite.inviteeId, userId), + eq(chatRoomInvite.status, 'accepted'), + ), + ) + .limit(1); + return accepted ? this.membership.findMemberRoom(accepted.roomId, userId) : null; + } + async declineRoomInvite({ userId, inviteId, diff --git a/packages/core/src/engagement/chat/service/chat-room-membership.service.ts b/packages/core/src/engagement/chat/service/chat-room-membership.service.ts index 2b36216d0..3a433bb6e 100644 --- a/packages/core/src/engagement/chat/service/chat-room-membership.service.ts +++ b/packages/core/src/engagement/chat/service/chat-room-membership.service.ts @@ -156,7 +156,7 @@ export class ChatRoomMembershipService { userAgent: meta.userAgent ?? null, }); // The member list hides staff from ordinary viewers, so their joins are not signalled. - if (!STAFF_USER_ROLES.some((role) => role === joinerRole)) { + if (!room.isPublic && !STAFF_USER_ROLES.some((role) => role === joinerRole)) { await this.signalMemberJoined(roomId, userId); } } @@ -212,6 +212,19 @@ export class ChatRoomMembershipService { }); } + async findMemberRoom(roomId: Uuid, userId: Uuid) { + const [row] = await this.drizzle.db + .select({ room: chatRoom }) + .from(chatRoom) + .innerJoin( + chatRoomMember, + and(eq(chatRoomMember.roomId, chatRoom.id), eq(chatRoomMember.userId, userId)), + ) + .where(and(eq(chatRoom.id, roomId), isNull(chatRoom.deletedAt))) + .limit(1); + return row ? toRoom(row.room) : null; + } + joinPublicRoom({ roomId, userId, ip, userAgent }: { roomId: Uuid; userId: Uuid } & ClientMeta) { return this.join({ roomId, diff --git a/packages/testing/src/__tests__/chat-room-invites.e2e.test.ts b/packages/testing/src/__tests__/chat-room-invites.e2e.test.ts index e4ecba556..53996b72b 100644 --- a/packages/testing/src/__tests__/chat-room-invites.e2e.test.ts +++ b/packages/testing/src/__tests__/chat-room-invites.e2e.test.ts @@ -187,6 +187,42 @@ describe('chat room invites: who may invite', () => { await expectRejected(await invite(member, room, invitee.userId), 403, 'forbidden'); }); + it('audits a refused invite, search and status lookup', async () => { + const owner = await registerChatter('host'); + const member = await registerChatter('member'); + const invitee = await registerChatter('guest'); + const room = await createRoom(owner); + await joinByCode(member, room); + + expect((await invite(member, room, invitee.userId)).status).toBe(403); + expect((await member.client.get(`/chat/rooms/${room.id}/invite-candidates?q=gue`)).status).toBe( + 403, + ); + expect( + ( + await member.client.post(`/chat/rooms/${room.id}/invite-statuses`, { + userIds: [invitee.userId], + }) + ).status, + ).toBe(403); + + for (const action of ['create', 'search', 'lookup']) { + await vi.waitFor(async () => { + const auditRes = await admin.get( + `/audit/logs?actorId=${member.playerId}&resourceId=chat.room_invite:${action}&action=identity.user.unauthorized_access`, + ); + expect(auditRes.status).toBe(200); + const { items } = (await auditRes.json()) as { items: unknown[] }; + expect(items[0]).toMatchObject({ + actorType: 'player', + actorId: member.playerId, + resourceType: 'chat.room_invite', + result: 'failure', + }); + }, WAIT); + } + }); + it('answers a non-member as if the room did not exist', async () => { const owner = await registerChatter('host'); const outsider = await registerChatter('outside'); @@ -245,6 +281,42 @@ describe('chat room invites: rejections', () => { await expectRejected(await invite(owner, room, blocker.userId), 403, 'unavailable'); }); + it('refuses a player under a responsible-gambling exclusion, without saying why', async () => { + const owner = await registerChatter('host'); + const room = await createRoom(owner); + const coolingOff = await registerChatter('resting'); + const selfExcluded = await registerChatter('excluded'); + const cooled = await admin.post(`/compliance/players/${coolingOff.userId}/cooling-off`, { + durationHours: 24, + reason: 'player requested a break', + }); + expect(cooled.status).toBe(200); + const excluded = await admin.post(`/compliance/players/${selfExcluded.userId}/self-exclusion`, { + isPermanent: false, + durationMonths: 6, + reason: 'player requested', + confirm: true, + }); + expect(excluded.status).toBe(200); + + await vi.waitFor(async () => { + const res = await owner.client.post(`/chat/rooms/${room.id}/invite-statuses`, { + userIds: [coolingOff.userId, selfExcluded.userId], + }); + expect(ChatRoomInviteLookupSchema.array().parse(await res.json())).toEqual([ + { userId: coolingOff.userId, inviteStatus: 'unavailable' }, + { userId: selfExcluded.userId, inviteStatus: 'unavailable' }, + ]); + }, WAIT); + for (const excludedPlayer of [coolingOff, selfExcluded]) { + await expectRejected(await invite(owner, room, excludedPlayer.userId), 403, 'unavailable'); + const search = await owner.client.get( + `/chat/rooms/${room.id}/invite-candidates?q=${excludedPlayer.username}`, + ); + expect(ChatRoomInviteCandidateSchema.array().parse(await search.json())).toEqual([]); + } + }); + it('refuses a suspended player', async () => { const owner = await registerChatter('host'); const suspended = await registerChatter('susp'); @@ -445,6 +517,58 @@ describe('chat room invites: accepting', () => { } }); + it('does not signal a join to a public room', async () => { + const member = await registerChatter('member'); + const created = await admin.post('/backoffice/chat/rooms', { + name: `public-${randomUUID()}`, + slug: `public-${randomUUID()}`, + category: 'games-sports', + }); + expect(created.status).toBe(200); + const room = ChatRoomSchema.parse(await created.json()); + const transport = app.container.get(CHAT_REALTIME_TRANSPORT); + const marker = `after-join-${randomUUID()}`; + + const signals = await watchRoomSignals(room.id); + try { + expect((await member.client.post(`/chat/rooms/${room.id}/join`)).status).toBe(200); + await transport.signal?.(chatChannel(room.id), marker, {}); + await vi.waitFor(() => { + expect(signals.received.some((signal) => signal.name === marker)).toBe(true); + }, WAIT); + expect(signals.received.some((s) => s.name === CHAT_MEMBER_JOINED_SIGNAL)).toBe(false); + } finally { + signals.unsubscribe(); + } + }); + + it('returns the joined room when an accept is retried', async () => { + const owner = await registerChatter('host'); + const invitee = await registerChatter('guest'); + const room = await createRoom(owner); + const sent = await sendInvite(owner, room, invitee); + + const first = await invitee.client.post(`/chat/invites/${sent.id}/accept`); + const retried = await invitee.client.post(`/chat/invites/${sent.id}/accept`); + + expect(first.status).toBe(200); + expect(retried.status).toBe(200); + expect(ChatRoomSchema.parse(await retried.json()).id).toBe(room.id); + expect(await inviteRow(sent.id)).toMatchObject({ status: 'accepted' }); + }); + + it('refuses a retried accept once the invitee has left the room', async () => { + const owner = await registerChatter('host'); + const invitee = await registerChatter('guest'); + const room = await createRoom(owner); + const sent = await sendInvite(owner, room, invitee); + expect((await invitee.client.post(`/chat/invites/${sent.id}/accept`)).status).toBe(200); + expect((await invitee.client.post(`/chat/rooms/${room.id}/leave`)).status).toBe(200); + + expect((await invitee.client.post(`/chat/invites/${sent.id}/accept`)).status).toBe(404); + expect(await roomMemberIds(owner, room)).not.toContain(invitee.userId); + }); + it('refuses an invite addressed to someone else', async () => { const owner = await registerChatter('host'); const invitee = await registerChatter('guest'); @@ -614,7 +738,7 @@ describe('chat room invites: declining and expiry', () => { }); describe('chat room invites: finding players', () => { - it('matches usernames only, hides blockers and reports each status', async () => { + it('matches usernames only, keeps blocks private and reports each status', async () => { const owner = await registerChatter('host'); const room = await createRoom(owner); const tag = `pk${randomInt(10 ** 4, 10 ** 5)}`; @@ -639,7 +763,7 @@ describe('chat room invites: finding players', () => { expect(byId.get(available.userId)).toBe('available'); expect(byId.get(member.userId)).toBe('member'); expect(byId.get(invited.userId)).toBe('invited'); - expect(byId.has(blocker.userId)).toBe(false); + expect(byId.get(blocker.userId)).toBe('available'); expect(byId.has(emailOnly.userId)).toBe(false); expect(byId.has(owner.userId)).toBe(false); expect(candidates.find((row) => row.userId === available.userId)).toMatchObject({ @@ -648,7 +772,7 @@ describe('chat room invites: finding players', () => { }); }); - it('reports a blocker as unavailable in the status lookup', async () => { + it('does not reveal a block in the status lookup', async () => { const owner = await registerChatter('host'); const room = await createRoom(owner); const blocker = await registerChatter('blocker'); @@ -664,7 +788,7 @@ describe('chat room invites: finding players', () => { const rows = ChatRoomInviteLookupSchema.array().parse(await res.json()); expect(rows).toEqual([ - { userId: blocker.userId, inviteStatus: 'unavailable' }, + { userId: blocker.userId, inviteStatus: 'available' }, { userId: available.userId, inviteStatus: 'available' }, { userId: expect.any(String), inviteStatus: 'unavailable' }, ]); From cce1caa758e5b2ebbe6b4c30065981a5bef557fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oleksandr=20Kapitu=C5=82a?= Date: Wed, 7 Oct 2026 15:17:01 +0200 Subject: [PATCH 5/5] fix(chat): close invite gaps around bans, exclusion and lapsed inviters - Withhold invites to restricted, excluded or blocking players instead of refusing them, and keep the invite list empty for an excluded player. - Expire pending invites when an inviter leaves, is removed, demoted, banned or closes their account, when the invitee is banned or closes their account, and when the room is deleted; an hourly sweep expires the rest, and every expiry is audited with its actor. - Enforce invite-only joining on private rooms, and let only the owner switch moderator invites, auditing a refused change. - Serialize invites against platform bans and admin room deletes, and index pending invites by inviter. --- .../src/audit/__tests__/map-event.test.ts | 53 ++ packages/core/src/audit/plugin.ts | 14 +- packages/core/src/contracts/schemas/events.ts | 12 +- .../chat-moderation-expiry.int.test.ts | 2 +- .../__tests__/chat-mute-listing.int.test.ts | 3 +- .../chat/__tests__/chat.service.int.test.ts | 2 +- .../__tests__/global-chat-room.int.test.ts | 2 +- .../room-ownership-handover.int.test.ts | 2 +- .../src/engagement/chat/contract/constants.ts | 2 +- .../migrations/0017_chat_room_invite.sql | 2 + .../migrations/meta/0017_snapshot.json | 25 +- .../drizzle/migrations/meta/_journal.json | 2 +- packages/core/src/engagement/chat/plugin.ts | 43 +- .../core/src/engagement/chat/router/index.ts | 10 +- .../core/src/engagement/chat/schema/index.ts | 4 + .../service/chat-access-refusal.service.ts | 34 ++ .../chat/service/chat-ban.service.ts | 23 +- .../chat/service/chat-moderation.service.ts | 10 +- .../chat/service/chat-room-ban.service.ts | 21 +- .../chat-room-invite-expiry.service.ts | 62 +++ .../chat/service/chat-room-invite.service.ts | 448 ++++++++-------- .../service/chat-room-membership.service.ts | 100 +++- .../engagement/chat/service/chat.service.ts | 111 +++- .../service/errors/chat-moderation.errors.ts | 5 + .../service/errors/chat-room-invite.errors.ts | 5 - .../chat-room-invite-lifecycle.e2e.test.ts | 485 ++++++++++++++++++ .../__tests__/chat-room-invites.e2e.test.ts | 143 ++++-- 27 files changed, 1287 insertions(+), 338 deletions(-) create mode 100644 packages/core/src/engagement/chat/service/chat-access-refusal.service.ts create mode 100644 packages/core/src/engagement/chat/service/chat-room-invite-expiry.service.ts create mode 100644 packages/testing/src/__tests__/chat-room-invite-lifecycle.e2e.test.ts diff --git a/packages/core/src/audit/__tests__/map-event.test.ts b/packages/core/src/audit/__tests__/map-event.test.ts index 00bed79f1..8d18745b7 100644 --- a/packages/core/src/audit/__tests__/map-event.test.ts +++ b/packages/core/src/audit/__tests__/map-event.test.ts @@ -1042,6 +1042,7 @@ describe('mapEventToRecord: chat.room.invite', () => { expect(row).toMatchObject({ actorType: 'system', + actorId: null, action: 'chat.room.invite.expired', resourceType: 'chat_room_invite', resourceId: inviteId, @@ -1050,6 +1051,38 @@ describe('mapEventToRecord: chat.room.invite', () => { }); }); + it('records an invite voided by a player against that player', async () => { + const row = await mapEventToRecord('chat.room.invite.expired', { + inviteId, + roomId, + inviterId, + inviteeId: userId, + actorId: inviterId, + actorPlayerId: playerId, + }); + + expect(row).toMatchObject({ + actorType: 'player', + actorId: playerId, + resourceType: 'chat_room_invite', + resourceId: inviteId, + after: { roomId, inviterId, inviteeId: userId, status: 'expired' }, + }); + }); + + it('records an invite voided by staff against the staff user id', async () => { + const row = await mapEventToRecord('chat.room.invite.expired', { + inviteId, + roomId, + inviterId, + inviteeId: userId, + actorId: adminId, + actorPlayerId: null, + }); + + expect(row).toMatchObject({ actorType: 'admin', actorId: adminId, resourceId: inviteId }); + }); + it('falls back to the inviter user id when no player backs the inviter', async () => { const row = await mapEventToRecord('chat.room.invite.sent', { inviteId, @@ -1063,4 +1096,24 @@ describe('mapEventToRecord: chat.room.invite', () => { expect(row).toMatchObject({ actorType: 'player', actorId: inviterId, resourceId: inviteId }); }); + + it('records a withheld invite against the inviter', async () => { + const row = await mapEventToRecord('chat.room.invite.withheld', { + inviteId, + roomId, + inviterId, + inviteeId: userId, + playerId, + }); + + expect(row).toMatchObject({ + actorType: 'player', + actorId: playerId, + action: 'chat.room.invite.withheld', + resourceType: 'chat_room_invite', + resourceId: inviteId, + result: 'success', + after: { roomId, inviteeId: userId, status: 'pending', withheld: true }, + }); + }); }); diff --git a/packages/core/src/audit/plugin.ts b/packages/core/src/audit/plugin.ts index 7109467b7..b3aa96ec5 100644 --- a/packages/core/src/audit/plugin.ts +++ b/packages/core/src/audit/plugin.ts @@ -473,14 +473,19 @@ export async function mapEventToRecord( // actorId = the acting player's resolved playerId (inviter on sent, invitee on accepted and // declined), falling back to their raw user id; resource = the invite row. A decline is the // invitee's own choice, so it records as a success despite the topic suffix. - if (topic === 'chat.room.invite.sent') { + if (topic === 'chat.room.invite.sent' || topic === 'chat.room.invite.withheld') { return { ...base, actorType: 'player', actorId: str(p['playerId']) ?? str(p['inviterId']), resourceType: 'chat_room_invite', resourceId: str(p['inviteId']), - after: { roomId: str(p['roomId']), inviteeId: str(p['inviteeId']), status: 'pending' }, + after: { + roomId: str(p['roomId']), + inviteeId: str(p['inviteeId']), + status: 'pending', + ...(topic === 'chat.room.invite.withheld' ? { withheld: true } : {}), + }, }; } @@ -501,8 +506,12 @@ export async function mapEventToRecord( } if (topic === 'chat.room.invite.expired') { + const actorId = str(p['actorId']); + const actorPlayerId = str(p['actorPlayerId']); return { ...base, + actorType: actorId ? (actorPlayerId ? 'player' : 'admin') : 'system', + actorId: actorId ? (actorPlayerId ?? actorId) : null, resourceType: 'chat_room_invite', resourceId: str(p['inviteId']), after: { @@ -1376,6 +1385,7 @@ const SUBSCRIBED_TOPICS: DomainEventName[] = [ 'chat.room.member.joined', 'chat.room.member.left', 'chat.room.invite.sent', + 'chat.room.invite.withheld', 'chat.room.invite.accepted', 'chat.room.invite.declined', 'chat.room.invite.expired', diff --git a/packages/core/src/contracts/schemas/events.ts b/packages/core/src/contracts/schemas/events.ts index 34fe0dcc3..fc7e09355 100644 --- a/packages/core/src/contracts/schemas/events.ts +++ b/packages/core/src/contracts/schemas/events.ts @@ -872,6 +872,14 @@ export const domainEventSchemas = { roomName: z.string(), playerId: UuidSchema.nullable(), }), + // An invite to a player who may not receive it is stored but never delivered. + 'chat.room.invite.withheld': authContextBase.extend({ + inviteId: UuidSchema, + roomId: UuidSchema, + inviterId: UuidSchema, + inviteeId: UuidSchema, + playerId: UuidSchema.nullable(), + }), 'chat.room.invite.accepted': authContextBase.extend({ inviteId: UuidSchema, roomId: UuidSchema, @@ -886,12 +894,14 @@ export const domainEventSchemas = { inviteeId: UuidSchema, playerId: UuidSchema.nullable(), }), - // A lapsed pending invite was retired when the same player was invited again. + // A pending invite lapsed or was voided; `actorId` is absent when the expiry sweep did it. 'chat.room.invite.expired': authContextBase.extend({ inviteId: UuidSchema, roomId: UuidSchema, inviterId: UuidSchema, inviteeId: UuidSchema, + actorId: UuidSchema.optional(), + actorPlayerId: UuidSchema.nullable().optional(), }), 'chat.room.member.left': authContextBase.extend({ roomId: UuidSchema, diff --git a/packages/core/src/engagement/chat/__tests__/chat-moderation-expiry.int.test.ts b/packages/core/src/engagement/chat/__tests__/chat-moderation-expiry.int.test.ts index 7228ee3b4..8c6991aae 100644 --- a/packages/core/src/engagement/chat/__tests__/chat-moderation-expiry.int.test.ts +++ b/packages/core/src/engagement/chat/__tests__/chat-moderation-expiry.int.test.ts @@ -81,7 +81,7 @@ beforeEach(async () => { describe('ChatModerationExpiryService.sweep', () => { const makeSweep = () => new ChatModerationExpiryService(db.drizzle, audit); const makeModeration = () => - new ChatModerationService(db.drizzle, mock({}), audit); + new ChatModerationService(db.drizzle, mock({}), audit, makeEventBus()); it('records exactly one audit entry for a lapsed mute, dated from its own expiresAt', async () => { const expiresAt = secondsFromNow(-30); diff --git a/packages/core/src/engagement/chat/__tests__/chat-mute-listing.int.test.ts b/packages/core/src/engagement/chat/__tests__/chat-mute-listing.int.test.ts index fe133fa9e..ff1d229d7 100644 --- a/packages/core/src/engagement/chat/__tests__/chat-mute-listing.int.test.ts +++ b/packages/core/src/engagement/chat/__tests__/chat-mute-listing.int.test.ts @@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto'; import { eq, sql } from 'drizzle-orm'; import { createTestDb, type TestDb } from '@openora/core/testing'; import type { AuditWritePort, RealtimeTransport } from '@openora/core/contracts'; -import { mock, NO_CLIENT_META } from '../../../testing/mock.js'; +import { makeEventBus, mock, NO_CLIENT_META } from '../../../testing/mock.js'; import { migrate } from '../migrate.js'; import { chatMute } from '../schema/index.js'; import { ChatModerationService } from '../service/chat-moderation.service.js'; @@ -22,6 +22,7 @@ const makeModeration = () => record: vi.fn().mockResolvedValue(undefined), recordInTransaction: vi.fn().mockResolvedValue(undefined), }), + makeEventBus(), ); async function seedMute(overrides: Partial = {}) { diff --git a/packages/core/src/engagement/chat/__tests__/chat.service.int.test.ts b/packages/core/src/engagement/chat/__tests__/chat.service.int.test.ts index 99db0190e..5b5e7adeb 100644 --- a/packages/core/src/engagement/chat/__tests__/chat.service.int.test.ts +++ b/packages/core/src/engagement/chat/__tests__/chat.service.int.test.ts @@ -130,7 +130,7 @@ function makeService( record: vi.fn().mockResolvedValue(undefined), recordInTransaction: vi.fn().mockResolvedValue(undefined), }); - const moderation = new ChatModerationService(db.drizzle, transport, audit); + const moderation = new ChatModerationService(db.drizzle, transport, audit, events); const identityReader = makeIdentityReader(); const chatService = new ChatService({ drizzle: db.drizzle, diff --git a/packages/core/src/engagement/chat/__tests__/global-chat-room.int.test.ts b/packages/core/src/engagement/chat/__tests__/global-chat-room.int.test.ts index 844289040..3548a16e9 100644 --- a/packages/core/src/engagement/chat/__tests__/global-chat-room.int.test.ts +++ b/packages/core/src/engagement/chat/__tests__/global-chat-room.int.test.ts @@ -41,7 +41,7 @@ function makeService() { record: vi.fn().mockResolvedValue(undefined), recordInTransaction: vi.fn().mockResolvedValue(undefined), }); - const moderation = new ChatModerationService(db.drizzle, transport, audit); + const moderation = new ChatModerationService(db.drizzle, transport, audit, events); const directory = mock({ lookupPlayers: async () => [] }); return new ChatService({ drizzle: db.drizzle, diff --git a/packages/core/src/engagement/chat/__tests__/room-ownership-handover.int.test.ts b/packages/core/src/engagement/chat/__tests__/room-ownership-handover.int.test.ts index 489ca6279..7bf23f0f8 100644 --- a/packages/core/src/engagement/chat/__tests__/room-ownership-handover.int.test.ts +++ b/packages/core/src/engagement/chat/__tests__/room-ownership-handover.int.test.ts @@ -79,7 +79,7 @@ function makeServices(transport: RealtimeTransport = makeTransport()) { })), }); const identityReader = makeIdentityReader(); - const moderation = new ChatModerationService(db.drizzle, transport, audit); + const moderation = new ChatModerationService(db.drizzle, transport, audit, events); const chat = new ChatService({ drizzle: db.drizzle, events, diff --git a/packages/core/src/engagement/chat/contract/constants.ts b/packages/core/src/engagement/chat/contract/constants.ts index e68be60ef..504776f90 100644 --- a/packages/core/src/engagement/chat/contract/constants.ts +++ b/packages/core/src/engagement/chat/contract/constants.ts @@ -39,7 +39,7 @@ export const CHAT_ROOM_INVITE_CANDIDATE_STATUSES = [ 'banned', ] as const; -// `unavailable` hides why a player cannot be invited. +// `unavailable` marks an id that is not a player. export const CHAT_ROOM_INVITE_LOOKUP_STATUSES = [ ...CHAT_ROOM_INVITE_CANDIDATE_STATUSES, 'unavailable', diff --git a/packages/core/src/engagement/chat/drizzle/migrations/0017_chat_room_invite.sql b/packages/core/src/engagement/chat/drizzle/migrations/0017_chat_room_invite.sql index 9797865b8..466d58a80 100644 --- a/packages/core/src/engagement/chat/drizzle/migrations/0017_chat_room_invite.sql +++ b/packages/core/src/engagement/chat/drizzle/migrations/0017_chat_room_invite.sql @@ -5,6 +5,7 @@ CREATE TABLE "chat_room_invite" ( "inviter_id" uuid NOT NULL, "invitee_id" uuid NOT NULL, "status" "chat_room_invite_status" DEFAULT 'pending' NOT NULL, + "withheld" boolean DEFAULT false NOT NULL, "created_at" timestamp with time zone DEFAULT now() NOT NULL, "responded_at" timestamp with time zone ); @@ -12,4 +13,5 @@ CREATE TABLE "chat_room_invite" ( ALTER TABLE "chat_room_invite" ADD CONSTRAINT "chat_room_invite_room_id_chat_room_id_fk" FOREIGN KEY ("room_id") REFERENCES "public"."chat_room"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint CREATE UNIQUE INDEX "chat_room_invite_pending_room_invitee_key" ON "chat_room_invite" USING btree ("room_id","invitee_id") WHERE "chat_room_invite"."status" = 'pending';--> statement-breakpoint CREATE INDEX "chat_room_invite_invitee_status_idx" ON "chat_room_invite" USING btree ("invitee_id","status");--> statement-breakpoint +CREATE INDEX "chat_room_invite_inviter_pending_idx" ON "chat_room_invite" USING btree ("inviter_id") WHERE "chat_room_invite"."status" = 'pending';--> statement-breakpoint CREATE INDEX "chat_room_invite_room_idx" ON "chat_room_invite" USING btree ("room_id"); \ No newline at end of file diff --git a/packages/core/src/engagement/chat/drizzle/migrations/meta/0017_snapshot.json b/packages/core/src/engagement/chat/drizzle/migrations/meta/0017_snapshot.json index 33b9323c5..1ef3f1239 100644 --- a/packages/core/src/engagement/chat/drizzle/migrations/meta/0017_snapshot.json +++ b/packages/core/src/engagement/chat/drizzle/migrations/meta/0017_snapshot.json @@ -1,5 +1,5 @@ { - "id": "a4336416-e71a-4d7c-9637-1e20b8cd04c3", + "id": "289e00df-fe54-41f5-98f8-21425e3942be", "prevId": "550e8414-4ccc-4253-b985-e957c81c4511", "version": "7", "dialect": "postgresql", @@ -967,6 +967,13 @@ "notNull": true, "default": "'pending'" }, + "withheld": { + "name": "withheld", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, "created_at": { "name": "created_at", "type": "timestamp with time zone", @@ -1025,6 +1032,22 @@ "method": "btree", "with": {} }, + "chat_room_invite_inviter_pending_idx": { + "name": "chat_room_invite_inviter_pending_idx", + "columns": [ + { + "expression": "inviter_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat_room_invite\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, "chat_room_invite_room_idx": { "name": "chat_room_invite_room_idx", "columns": [ diff --git a/packages/core/src/engagement/chat/drizzle/migrations/meta/_journal.json b/packages/core/src/engagement/chat/drizzle/migrations/meta/_journal.json index de98bb89d..286ffaf66 100644 --- a/packages/core/src/engagement/chat/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/engagement/chat/drizzle/migrations/meta/_journal.json @@ -124,7 +124,7 @@ { "idx": 17, "version": "7", - "when": 1791196667898, + "when": 1791376113668, "tag": "0017_chat_room_invite", "breakpoints": true } diff --git a/packages/core/src/engagement/chat/plugin.ts b/packages/core/src/engagement/chat/plugin.ts index 9da9ce6bb..57a96f567 100644 --- a/packages/core/src/engagement/chat/plugin.ts +++ b/packages/core/src/engagement/chat/plugin.ts @@ -43,12 +43,15 @@ const CHAT_ROOM_PURGE_SCAN_QUEUE = queue('chat-room-purge-scan'); const CHAT_ROOM_PURGE_QUEUE = queue('chat-room-purge'); const CHAT_ACCOUNT_CLOSED_QUEUE = queue('chat-account-closed'); const CHAT_ACCOUNT_REOPENED_QUEUE = queue('chat-account-reopened'); +const CHAT_ROOM_INVITE_EXPIRY_QUEUE = queue('chat-room-invite-expiry'); const CHAT_ROOM_PURGE_CRON = '15 3 * * *'; const CHAT_ROOM_PURGE_TIMEZONE = 'UTC'; const CHAT_ROOM_PURGE_BATCH = 500; +const CHAT_ROOM_INVITE_EXPIRY_CRON = '0 * * * *'; + const RETRY = { attempts: 5, backoff: { type: 'exponential', delayMs: 1000 } } as const; const accountJobKey = (prefix: string, userId: string, at: Date) => @@ -66,6 +69,7 @@ export default { register(ctx) { const logger = createLogger('chat'); let expiryRef: ChatModerationExpiryService | undefined; + let inviteRef: ChatRoomInviteService | undefined; ctx.provide(CHAT_REALTIME_TRANSPORT, (c) => c.get(REALTIME_TRANSPORT)); ctx.provide(CHAT_REALTIME_CLIENT_AUTHORIZER, (c) => c.get(REALTIME_CLIENT_AUTHORIZER)); ctx.provide( @@ -75,6 +79,7 @@ export default { c.get(DRIZZLE), c.get(CHAT_REALTIME_TRANSPORT), c.get(AUDIT_WRITER), + c.get(EVENT_BUS), ), ); const createChatService = ( @@ -134,6 +139,21 @@ export default { }, }); + ctx.jobs.worker({ + queue: CHAT_ROOM_INVITE_EXPIRY_QUEUE, + schema: EmptyJobPayloadSchema, + handler: async () => { + if (!inviteRef) { + logger.warn('chat-room-invite-expiry sweep skipped - service not constructed'); + return; + } + const expired = await inviteRef.expireLapsedInvites(); + if (expired > 0) { + logger.info({ expired }, 'chat room invites expired'); + } + }, + }); + let membershipRef: ChatRoomMembershipService | null = null; let purgeRef: ChatRoomPurgeService | null = null; let jobQueueRef: JobQueueAdapter | null = null; @@ -319,16 +339,25 @@ export default { { cron: CHAT_ROOM_PURGE_CRON, timezone: CHAT_ROOM_PURGE_TIMEZONE }, ) .catch((err: unknown) => logger.error({ err }, 'chat-room-purge schedule failed')); + inviteRef = new ChatRoomInviteService( + c.get(DRIZZLE), + c.get(EVENT_BUS), + c.get(IDENTITY_READER), + membershipService, + c.has(PLAY_ELIGIBILITY) ? c.get(PLAY_ELIGIBILITY) : undefined, + ); + void jobQueueRef + .schedule( + CHAT_ROOM_INVITE_EXPIRY_QUEUE, + 'chat-room-invite-expiry.cron', + {}, + { cron: CHAT_ROOM_INVITE_EXPIRY_CRON, timezone: CHAT_ROOM_PURGE_TIMEZONE }, + ) + .catch((err: unknown) => logger.error({ err }, 'chat-room-invite-expiry schedule failed')); return createChatRouter({ chatService, membershipService, - inviteService: new ChatRoomInviteService( - c.get(DRIZZLE), - c.get(EVENT_BUS), - c.get(IDENTITY_READER), - membershipService, - c.has(PLAY_ELIGIBILITY) ? c.get(PLAY_ELIGIBILITY) : undefined, - ), + inviteService: inviteRef, roomBanService: new ChatRoomBanService( c.get(DRIZZLE), c.get(EVENT_BUS), diff --git a/packages/core/src/engagement/chat/router/index.ts b/packages/core/src/engagement/chat/router/index.ts index d30ebaae2..6220d411b 100644 --- a/packages/core/src/engagement/chat/router/index.ts +++ b/packages/core/src/engagement/chat/router/index.ts @@ -49,6 +49,7 @@ import { ChatPlayerMutedError, ChatPlayerBannedError, ChatAdminPrivateRoomModerationError, + ChatRoomInviteOnlyError, ChatRoomLockedError, ChatRoomNotFoundError, } from '../service/chat-moderation.service.js'; @@ -62,7 +63,6 @@ import { ChatRoomInviteeAlreadyMemberError, ChatRoomInviteeBannedError, ChatRoomInviteeNotPlayerError, - ChatRoomInviteeUnavailableError, ChatRoomInviterBlockedError, } from '../service/errors/chat-room-invite.errors.js'; import { ChatRoomBanService } from '../service/chat-room-ban.service.js'; @@ -396,7 +396,7 @@ export function createChatRouter({ return mapErrors( { NOT_FOUND: ChatRoomJoinCodeNotFoundError, - FORBIDDEN: [ChatRoomBannedError, ChatRoomLockedError], + FORBIDDEN: [ChatRoomBannedError, ChatRoomLockedError, ChatRoomInviteOnlyError], }, () => membershipService.joinRoom({ userId, joinCode: input.joinCode, ...context.clientMeta }), @@ -411,7 +411,10 @@ export function createChatRouter({ JOIN_ROOM_RATE_LIMIT, ); return mapErrors( - { NOT_FOUND: ChatRoomNotFoundError, FORBIDDEN: [ChatRoomBannedError, ChatRoomLockedError] }, + { + NOT_FOUND: ChatRoomNotFoundError, + FORBIDDEN: [ChatRoomBannedError, ChatRoomLockedError, ChatRoomInviteOnlyError], + }, () => membershipService.joinPublicRoom({ roomId: input.roomId, userId, ...context.clientMeta }), ); @@ -449,7 +452,6 @@ export function createChatRouter({ NOT_FOUND: ChatRoomNotFoundError, FORBIDDEN: [ ChatRoomInviteForbiddenError, - ChatRoomInviteeUnavailableError, ChatRoomInviteeBannedError, ChatRoomLockedError, ChatRoomBannedError, diff --git a/packages/core/src/engagement/chat/schema/index.ts b/packages/core/src/engagement/chat/schema/index.ts index e5e3af5a1..ae1c08bef 100644 --- a/packages/core/src/engagement/chat/schema/index.ts +++ b/packages/core/src/engagement/chat/schema/index.ts @@ -155,6 +155,7 @@ export const chatRoomInvite = pgTable( inviterId: uuid().notNull(), inviteeId: uuid().notNull(), status: chatRoomInviteStatus().notNull().default('pending'), + withheld: boolean().notNull().default(false), createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), respondedAt: timestamp({ withTimezone: true }), }, @@ -163,6 +164,9 @@ export const chatRoomInvite = pgTable( .on(t.roomId, t.inviteeId) .where(sql`${t.status} = 'pending'`), index('chat_room_invite_invitee_status_idx').on(t.inviteeId, t.status), + index('chat_room_invite_inviter_pending_idx') + .on(t.inviterId) + .where(sql`${t.status} = 'pending'`), index('chat_room_invite_room_idx').on(t.roomId), ], ); diff --git a/packages/core/src/engagement/chat/service/chat-access-refusal.service.ts b/packages/core/src/engagement/chat/service/chat-access-refusal.service.ts new file mode 100644 index 000000000..db98d6ac5 --- /dev/null +++ b/packages/core/src/engagement/chat/service/chat-access-refusal.service.ts @@ -0,0 +1,34 @@ +import { eq } from 'drizzle-orm'; +import type { DrizzleDb, DrizzleTx, EventBus } from '@openora/core/server'; +import type { ClientMeta, Uuid } from '@openora/core/contracts'; +import { user } from '@openora/core/pam/schema/identity'; +import { player } from '@openora/core/pam/schema/profile'; + +// A chat-level refusal never reaches a shared guard, so it owes the audit log AdminGuard's signal. +export async function emitAccessRefused( + db: DrizzleDb | DrizzleTx, + events: EventBus, + { + actorId, + resource, + action, + ip, + userAgent, + }: { actorId: Uuid; resource: string; action: string } & ClientMeta, +) { + const [caller] = await db + .select({ role: user.role, playerId: player.id }) + .from(user) + .leftJoin(player, eq(player.userId, user.id)) + .where(eq(user.id, actorId)) + .limit(1); + events.emit('identity.user.unauthorized_access', { + userId: actorId, + playerId: caller?.playerId ?? null, + resource, + action, + ...(caller?.role ? { role: caller.role } : {}), + ip: ip ?? null, + userAgent: userAgent ?? null, + }); +} diff --git a/packages/core/src/engagement/chat/service/chat-ban.service.ts b/packages/core/src/engagement/chat/service/chat-ban.service.ts index 41bd52b0c..5ccc2745c 100644 --- a/packages/core/src/engagement/chat/service/chat-ban.service.ts +++ b/packages/core/src/engagement/chat/service/chat-ban.service.ts @@ -6,6 +6,7 @@ import { serializeRow, withAdvisoryXactLock, } from '@openora/core/server'; +import type { EventBus } from '@openora/core/server'; import type { AuditWritePort, ChatModerationRoomId, @@ -16,12 +17,21 @@ import type { Uuid, } from '@openora/core/contracts'; import { chatPlatformBan, chatRoom, chatRoomMember } from '../schema/index.js'; -import { resolveModerationTarget, type ModerationTarget } from '../moderation/index.js'; +import { + platformScopesFor, + resolveModerationTarget, + type ModerationTarget, +} from '../moderation/index.js'; import { revokeChannelBestEffort, ROOM_REVOKE_CONCURRENCY } from './channel-revoke.service.js'; import { retireLapsedRows } from './chat-moderation-expiry.service.js'; +import { emitExpiredInvites, expireInvitesInvolving } from './chat-room-invite-expiry.service.js'; const logger = createLogger('chat'); +export function chatPlatformBanLockKey(userId: Uuid) { + return `chat-platform-ban:${userId}`; +} + function activeBanFilter(userId: Uuid, target: ModerationTarget) { return and( eq(chatPlatformBan.userId, userId), @@ -35,6 +45,7 @@ export class ChatBanService { constructor( private readonly drizzle: DrizzleService, private readonly audit: AuditWritePort, + private readonly events: EventBus, private readonly transport?: RealtimeTransport, ) {} @@ -56,8 +67,8 @@ export class ChatBanService { const target = await resolveModerationTarget(this.drizzle.db, roomId, { validate: true }); const expiresAt = durationSeconds === null ? null : new Date(Date.now() + durationSeconds * 1000); - await this.drizzle.db.transaction((t) => - withAdvisoryXactLock(t, `chat-platform-ban:${userId}`, async () => { + const expired = await this.drizzle.db.transaction((t) => + withAdvisoryXactLock(t, chatPlatformBanLockKey(userId), async () => { const now = new Date(); await retireLapsedRows(t, chatPlatformBan, activeBanFilter(userId, target), now); const [previous] = await t @@ -103,8 +114,12 @@ export class ChatBanService { ip: ip ?? null, userAgent: userAgent ?? null, }); + return platformScopesFor('private').includes(target.scope) + ? expireInvitesInvolving(t, userId) + : []; }), ); + emitExpiredInvites(this.events, expired, { actorId, ip, userAgent }); // The ban is committed; failing to list its channels must not turn it into a 500 and a retry. await this.roomsToRevoke(userId, target) .then((roomIds) => this.revokeChannels(userId, roomIds)) @@ -152,7 +167,7 @@ export class ChatBanService { }: { userId: Uuid; roomId: ChatModerationRoomId; actorId: Uuid } & ClientMeta) { const target = await resolveModerationTarget(this.drizzle.db, roomId, { validate: false }); await this.drizzle.db.transaction((t) => - withAdvisoryXactLock(t, `chat-platform-ban:${userId}`, async () => { + withAdvisoryXactLock(t, chatPlatformBanLockKey(userId), async () => { const liftedAt = new Date(); const [lifted] = await t .update(chatPlatformBan) diff --git a/packages/core/src/engagement/chat/service/chat-moderation.service.ts b/packages/core/src/engagement/chat/service/chat-moderation.service.ts index 97ae3cb1b..cd5d8082c 100644 --- a/packages/core/src/engagement/chat/service/chat-moderation.service.ts +++ b/packages/core/src/engagement/chat/service/chat-moderation.service.ts @@ -12,16 +12,22 @@ import { ChatMuteService } from './chat-mute.service.js'; export * from './errors/chat-moderation.errors.js'; export { ChatMessageNotFoundError } from './chat-message-moderation.service.js'; import { DrizzleService } from '@openora/core/server'; +import type { EventBus } from '@openora/core/server'; export class ChatModerationService implements ChatModeration { private readonly messages: ChatMessageModerationService; private readonly mutes: ChatMuteService; private readonly bans: ChatBanService; - constructor(drizzle: DrizzleService, transport: RealtimeTransport, audit: AuditWritePort) { + constructor( + drizzle: DrizzleService, + transport: RealtimeTransport, + audit: AuditWritePort, + events: EventBus, + ) { this.messages = new ChatMessageModerationService(drizzle, transport, audit); this.mutes = new ChatMuteService(drizzle, audit); - this.bans = new ChatBanService(drizzle, audit, transport); + this.bans = new ChatBanService(drizzle, audit, events, transport); } assertCanSend(userId: Uuid, roomId: Uuid | null, isPublic = true) { diff --git a/packages/core/src/engagement/chat/service/chat-room-ban.service.ts b/packages/core/src/engagement/chat/service/chat-room-ban.service.ts index 695177da9..fb1d8bf82 100644 --- a/packages/core/src/engagement/chat/service/chat-room-ban.service.ts +++ b/packages/core/src/engagement/chat/service/chat-room-ban.service.ts @@ -15,6 +15,7 @@ import { ChatRoomSelfModerationError, } from './errors/chat-moderation.errors.js'; import { revokeRoomChannelBestEffort } from './channel-revoke.service.js'; +import { emitExpiredInvites, expireInvitesInvolving } from './chat-room-invite-expiry.service.js'; export class ChatRoomBanService { constructor( @@ -72,7 +73,7 @@ export class ChatRoomBanService { } const expiresAt = durationSeconds === null ? null : new Date(Date.now() + durationSeconds * 1000); - const replaced = await this.drizzle.db.transaction((t) => + const { replaced, expired } = await this.drizzle.db.transaction((t) => withAdvisoryXactLock(t, `chat-room:${roomId}`, async () => { await this.assertModerator(t, roomId, moderatorId, userId); const now = new Date(); @@ -91,16 +92,26 @@ export class ChatRoomBanService { await t .delete(chatRoomMember) .where(and(eq(chatRoomMember.roomId, roomId), eq(chatRoomMember.userId, userId))); - return previous - ? { banId: previous.id, expiresAt: previous.expiresAt?.toISOString() ?? null } - : null; + return { + replaced: previous + ? { banId: previous.id, expiresAt: previous.expiresAt?.toISOString() ?? null } + : null, + expired: await expireInvitesInvolving(t, userId, roomId), + }; }), ); + const playerId = await this.identityReader.getPlayerIdByUserIdSafe(moderatorId); + emitExpiredInvites(this.events, expired, { + actorId: moderatorId, + actorPlayerId: playerId, + ip, + userAgent, + }); this.events.emit('chat.room.member.banned', { roomId, userId, bannedBy: moderatorId, - playerId: await this.identityReader.getPlayerIdByUserIdSafe(moderatorId), + playerId, reason, expiresAt: expiresAt?.toISOString() ?? null, replaced, diff --git a/packages/core/src/engagement/chat/service/chat-room-invite-expiry.service.ts b/packages/core/src/engagement/chat/service/chat-room-invite-expiry.service.ts new file mode 100644 index 000000000..3b8db45de --- /dev/null +++ b/packages/core/src/engagement/chat/service/chat-room-invite-expiry.service.ts @@ -0,0 +1,62 @@ +import { and, eq, or, type SQL } from 'drizzle-orm'; +import type { DrizzleDb, DrizzleTx, EventBus } from '@openora/core/server'; +import type { ClientMeta, Uuid } from '@openora/core/contracts'; +import { chatRoomInvite } from '../schema/index.js'; + +export type ExpiredInvite = { id: Uuid; roomId: Uuid; inviterId: Uuid; inviteeId: Uuid }; + +export type InviteExpiryMeta = Partial & { + actorId?: Uuid; + actorPlayerId?: Uuid | null; +}; + +export function expirePendingInvites( + db: DrizzleDb | DrizzleTx, + condition: SQL | undefined, +): Promise { + return db + .update(chatRoomInvite) + .set({ status: 'expired' }) + .where(and(eq(chatRoomInvite.status, 'pending'), condition)) + .returning({ + id: chatRoomInvite.id, + roomId: chatRoomInvite.roomId, + inviterId: chatRoomInvite.inviterId, + inviteeId: chatRoomInvite.inviteeId, + }); +} + +export function expireInvitesSentBy(db: DrizzleDb | DrizzleTx, roomId: Uuid, inviterId: Uuid) { + return expirePendingInvites( + db, + and(eq(chatRoomInvite.roomId, roomId), eq(chatRoomInvite.inviterId, inviterId)), + ); +} + +export function expireInvitesInvolving(db: DrizzleDb | DrizzleTx, userId: Uuid, roomId?: Uuid) { + return expirePendingInvites( + db, + and( + roomId ? eq(chatRoomInvite.roomId, roomId) : undefined, + or(eq(chatRoomInvite.inviterId, userId), eq(chatRoomInvite.inviteeId, userId)), + ), + ); +} + +export function emitExpiredInvites( + events: EventBus, + expired: ExpiredInvite[], + { actorId, actorPlayerId, ip, userAgent }: InviteExpiryMeta = {}, +) { + for (const row of expired) { + events.emit('chat.room.invite.expired', { + inviteId: row.id, + roomId: row.roomId, + inviterId: row.inviterId, + inviteeId: row.inviteeId, + ...(actorId ? { actorId, actorPlayerId: actorPlayerId ?? null } : {}), + ip: ip ?? null, + userAgent: userAgent ?? null, + }); + } +} diff --git a/packages/core/src/engagement/chat/service/chat-room-invite.service.ts b/packages/core/src/engagement/chat/service/chat-room-invite.service.ts index 62b72c64a..36f679096 100644 --- a/packages/core/src/engagement/chat/service/chat-room-invite.service.ts +++ b/packages/core/src/engagement/chat/service/chat-room-invite.service.ts @@ -10,17 +10,17 @@ import { isNull, ne, notExists, - notInArray, or, sql, + type SQL, } from 'drizzle-orm'; import { DrizzleService, likeContains, likePrefix, - mapConcurrent, serializeRow, withAdvisoryXactLock, + withAdvisoryXactLocks, } from '@openora/core/server'; import type { DrizzleDb, DrizzleTx, EventBus } from '@openora/core/server'; import type { @@ -60,18 +60,24 @@ import { ChatRoomInviteeAlreadyMemberError, ChatRoomInviteeBannedError, ChatRoomInviteeNotPlayerError, - ChatRoomInviteeUnavailableError, ChatRoomInviterBlockedError, } from './errors/chat-room-invite.errors.js'; import type { ChatRoomMembershipService } from './chat-room-membership.service.js'; +import { chatPlatformBanLockKey } from './chat-ban.service.js'; +import { emitAccessRefused } from './chat-access-refusal.service.js'; +import { + emitExpiredInvites, + expirePendingInvites, + type ExpiredInvite, +} from './chat-room-invite-expiry.service.js'; const DAY_MS = 24 * 60 * 60 * 1000; const MY_INVITES_LIMIT = 100; -const UNAVAILABLE_PLAYER_STATUSES = ['self_excluded', 'suspended', 'closed'] as const; +const EXCLUDED_PLAYER_STATUSES = ['self_excluded', 'suspended', 'closed'] as const; -const ELIGIBILITY_CHECK_CONCURRENCY = 8; +const INVITE_EXPIRY_BATCH_SIZE = 500; const INVITE_AUDIT_RESOURCE = 'chat.room_invite'; @@ -84,11 +90,17 @@ const invitingRole = or( and(eq(chatRoomMember.role, 'moderator'), eq(chatRoomConfiguration.moderatorInvite, true)), ); -// An invite lapses after the expiry window or once its inviter may no longer invite. +// An invite lapses after the expiry window, once its room closes, or once its inviter may no longer invite. function liveInvite(db: Db, now: Date) { return and( eq(chatRoomInvite.status, 'pending'), gt(chatRoomInvite.createdAt, new Date(now.getTime() - ROOM_INVITE_EXPIRY_DAYS * DAY_MS)), + exists( + db + .select({ id: chatRoom.id }) + .from(chatRoom) + .where(aliveInvitableRoom(chatRoomInvite.roomId)), + ), exists( db .select({ id: chatRoomMember.id }) @@ -117,6 +129,10 @@ function liveInvite(db: Db, now: Date) { ); } +function deliverableInvite(db: Db, now: Date) { + return and(liveInvite(db, now), eq(chatRoomInvite.withheld, false)); +} + function activeRoomBan(roomId: Uuid, now: Date) { return and( eq(chatRoomBan.roomId, roomId), @@ -145,7 +161,25 @@ function activeBlock(blockerId: Uuid, blockedId: Uuid) { ); } -function aliveInvitableRoom(roomId: Uuid) { +async function hasActivePlatformBan(db: Db, userId: Uuid, roomId: Uuid, now: Date) { + const [ban] = await db + .select({ id: chatPlatformBan.id }) + .from(chatPlatformBan) + .where(and(eq(chatPlatformBan.userId, userId), activePlatformBan(roomId, now))) + .limit(1); + return ban !== undefined; +} + +async function hasActiveBlock(db: Db, blockerId: Uuid, blockedId: Uuid) { + const [block] = await db + .select({ blockerId: chatUserBlock.blockerId }) + .from(chatUserBlock) + .where(activeBlock(blockerId, blockedId)) + .limit(1); + return block !== undefined; +} + +function aliveInvitableRoom(roomId: Uuid | typeof chatRoomInvite.roomId) { return and( eq(chatRoom.id, roomId), eq(chatRoom.isPublic, false), @@ -154,22 +188,16 @@ function aliveInvitableRoom(roomId: Uuid) { ); } -function memberRow(db: Db, roomId: Uuid) { - return db +function inviteStatus(db: Db, roomId: Uuid, now: Date) { + const member = db .select({ id: chatRoomMember.id }) .from(chatRoomMember) .where(and(eq(chatRoomMember.roomId, roomId), eq(chatRoomMember.userId, user.id))); -} - -function roomBanRow(db: Db, roomId: Uuid, now: Date) { - return db + const roomBan = db .select({ id: chatRoomBan.id }) .from(chatRoomBan) .where(and(activeRoomBan(roomId, now), eq(chatRoomBan.userId, user.id))); -} - -function liveInviteRow(db: Db, roomId: Uuid, now: Date) { - return db + const pendingInvite = db .select({ id: chatRoomInvite.id }) .from(chatRoomInvite) .where( @@ -179,36 +207,13 @@ function liveInviteRow(db: Db, roomId: Uuid, now: Date) { liveInvite(db, now), ), ); -} - -function inviteStatus(db: Db, roomId: Uuid, now: Date) { return sql`case - when ${exists(memberRow(db, roomId))} then 'member' - when ${exists(roomBanRow(db, roomId, now))} then 'banned' - when ${exists(liveInviteRow(db, roomId, now))} then 'invited' + when ${exists(member)} then 'member' + when ${exists(roomBan)} then 'banned' + when ${exists(pendingInvite)} then 'invited' else 'available' end`; } -// Platform bans and account state only hide a player who would otherwise read as -// available; a member, room-banned or invited player keeps that status, so nothing leaks. -function visibleCandidate(db: Db, roomId: Uuid, now: Date) { - const knownToRoom = or( - exists(memberRow(db, roomId)), - exists(roomBanRow(db, roomId, now)), - exists(liveInviteRow(db, roomId, now)), - ); - const unmasked = and( - notInArray(player.status, [...UNAVAILABLE_PLAYER_STATUSES]), - notExists( - db - .select({ id: chatPlatformBan.id }) - .from(chatPlatformBan) - .where(and(activePlatformBan(roomId, now), eq(chatPlatformBan.userId, user.id))), - ), - ); - return and(eq(user.role, 'player'), or(knownToRoom, unmasked)); -} - export class ChatRoomInviteService { constructor( private readonly drizzle: DrizzleService, @@ -243,7 +248,12 @@ export class ChatRoomInviteService { .where(aliveInvitableRoom(roomId)) .limit(1); if (!room?.canInvite) { - await this.auditRefusal(db, actorId, action, meta); + await emitAccessRefused(db, this.events, { + actorId, + resource: INVITE_AUDIT_RESOURCE, + action, + ...meta, + }); throw room ? new ChatRoomInviteForbiddenError(roomId) : new ChatRoomNotFoundError(roomId); } return { @@ -253,39 +263,20 @@ export class ChatRoomInviteService { }; } - // This check rejects before any shared guard runs, so it owes the audit log AdminGuard's signal. - private async auditRefusal( - db: Db, - actorId: Uuid, - action: InviteAction, - { ip, userAgent }: ClientMeta, - ) { - const [caller] = await db - .select({ role: user.role, playerId: player.id }) - .from(user) - .leftJoin(player, eq(player.userId, user.id)) - .where(eq(user.id, actorId)) - .limit(1); - this.events.emit('identity.user.unauthorized_access', { - userId: actorId, - playerId: caller?.playerId ?? null, - resource: INVITE_AUDIT_RESOURCE, - action, - ...(caller?.role ? { role: caller.role } : {}), - ip: ip ?? null, - userAgent: userAgent ?? null, - }); + private async isRestricted(userId: Uuid) { + return (await this.playEligibility?.isRestricted(userId)) ?? false; } - private async restrictedAmong(userIds: Uuid[]) { - const eligibility = this.playEligibility; - if (!eligibility || userIds.length === 0) { - return new Set(); + private async isExcluded(userId: Uuid) { + if (await this.isRestricted(userId)) { + return true; } - const restricted = await mapConcurrent(userIds, ELIGIBILITY_CHECK_CONCURRENCY, (id) => - eligibility.isRestricted(id), - ); - return new Set(userIds.filter((_, index) => restricted[index])); + const [row] = await this.drizzle.db + .select({ status: player.status }) + .from(player) + .where(eq(player.userId, userId)) + .limit(1); + return EXCLUDED_PLAYER_STATUSES.some((status) => status === row?.status); } async inviteToRoom({ @@ -298,96 +289,92 @@ export class ChatRoomInviteService { if (actorId === userId) { throw new ChatRoomInviteSelfError(); } + const restricted = await this.isRestricted(userId); + const banLocks = [chatPlatformBanLockKey(actorId), chatPlatformBanLockKey(userId)]; const { invite, room, lapsed } = await this.drizzle.db.transaction((t) => - withAdvisoryXactLock(t, `chat-room:${roomId}`, async () => { - const now = new Date(); - const room = await this.assertCanInvite(t, actorId, roomId, 'create', { ip, userAgent }); - if (room.lockRoom) { - throw new ChatRoomLockedError(roomId); - } - const [inviterBan] = await t - .select({ id: chatPlatformBan.id }) - .from(chatPlatformBan) - .where(and(eq(chatPlatformBan.userId, actorId), activePlatformBan(roomId, now))) - .limit(1); - if (inviterBan) { - throw new ChatRoomBannedError(roomId); - } - const [target] = await t - .select({ - role: user.role, - status: player.status, - inviteStatus: inviteStatus(t, roomId, now), - visible: sql`coalesce(${visibleCandidate(t, roomId, now)}, false)`, - }) - .from(user) - .leftJoin(player, eq(player.userId, user.id)) - .where(eq(user.id, userId)) - .limit(1); - if (!target || target.role !== 'player' || target.status === null) { - throw new ChatRoomInviteeNotPlayerError(userId); - } - if (target.inviteStatus === 'member') { - throw new ChatRoomInviteeAlreadyMemberError(userId); - } - if (target.inviteStatus === 'banned') { - throw new ChatRoomInviteeBannedError(userId); - } - if (target.inviteStatus === 'invited') { - throw new ChatRoomInvitePendingError(userId); - } - const [block] = await t - .select({ blockerId: chatUserBlock.blockerId }) - .from(chatUserBlock) - .where(activeBlock(userId, actorId)) - .limit(1); - if (!target.visible || block || (await this.restrictedAmong([userId])).size > 0) { - throw new ChatRoomInviteeUnavailableError(userId); - } - // A lapsed invite still holds the one-pending slot in the partial unique index. - const lapsed = await t - .update(chatRoomInvite) - .set({ status: 'expired' }) - .where( - and( - eq(chatRoomInvite.roomId, roomId), - eq(chatRoomInvite.inviteeId, userId), - eq(chatRoomInvite.status, 'pending'), - ), - ) - .returning({ id: chatRoomInvite.id, inviterId: chatRoomInvite.inviterId }); - const [invite] = await t - .insert(chatRoomInvite) - .values({ roomId, inviterId: actorId, inviteeId: userId }) - .returning(); - if (!invite) { - throw new Error('chat room invite insert returned no row'); - } - return { invite, room, lapsed }; - }), + withAdvisoryXactLock(t, `chat-room:${roomId}`, () => + withAdvisoryXactLocks( + t, + banLocks, + async () => { + const now = new Date(); + const room = await this.assertCanInvite(t, actorId, roomId, 'create', { + ip, + userAgent, + }); + if (room.lockRoom) { + throw new ChatRoomLockedError(roomId); + } + if (await hasActivePlatformBan(t, actorId, roomId, now)) { + throw new ChatRoomBannedError(roomId); + } + const [target] = await t + .select({ + role: user.role, + status: player.status, + inviteStatus: inviteStatus(t, roomId, now), + }) + .from(user) + .leftJoin(player, eq(player.userId, user.id)) + .where(eq(user.id, userId)) + .limit(1); + if (!target || target.role !== 'player' || target.status === null) { + throw new ChatRoomInviteeNotPlayerError(userId); + } + if (target.inviteStatus === 'member') { + throw new ChatRoomInviteeAlreadyMemberError(userId); + } + if (target.inviteStatus === 'banned') { + throw new ChatRoomInviteeBannedError(userId); + } + if (target.inviteStatus === 'invited') { + throw new ChatRoomInvitePendingError(userId); + } + const withheld = + restricted || + EXCLUDED_PLAYER_STATUSES.some((status) => status === target.status) || + (await hasActivePlatformBan(t, userId, roomId, now)) || + (await hasActiveBlock(t, userId, actorId)); + // A lapsed invite still holds the one-pending slot in the partial unique index. + const lapsed = await expirePendingInvites( + t, + and(eq(chatRoomInvite.roomId, roomId), eq(chatRoomInvite.inviteeId, userId)), + ); + const [invite] = await t + .insert(chatRoomInvite) + .values({ roomId, inviterId: actorId, inviteeId: userId, withheld }) + .returning(); + if (!invite) { + throw new Error('chat room invite insert returned no row'); + } + return { invite, room, lapsed }; + }, + 'shared', + ), + ), ); - for (const expired of lapsed) { - this.events.emit('chat.room.invite.expired', { - inviteId: expired.id, - roomId, - inviterId: expired.inviterId, - inviteeId: userId, - ip: ip ?? null, - userAgent: userAgent ?? null, - }); - } - this.events.emit('chat.room.invite.sent', { + const playerId = await this.identityReader.getPlayerIdByUserIdSafe(actorId); + emitExpiredInvites(this.events, lapsed, { actorId, actorPlayerId: playerId, ip, userAgent }); + const { withheld, ...sent } = invite; + const recorded = { inviteId: invite.id, roomId, inviterId: actorId, inviteeId: userId, - inviterUsername: room.inviterUsername, - roomName: room.name, - playerId: await this.identityReader.getPlayerIdByUserIdSafe(actorId), + playerId, ip: ip ?? null, userAgent: userAgent ?? null, - }); - return serializeRow(invite, { dateFields: ['createdAt', 'respondedAt'] }); + }; + if (withheld) { + this.events.emit('chat.room.invite.withheld', recorded); + } else { + this.events.emit('chat.room.invite.sent', { + ...recorded, + inviterUsername: room.inviterUsername, + roomName: room.name, + }); + } + return serializeRow(sent, { dateFields: ['createdAt', 'respondedAt'] }); } async searchRoomInviteCandidates({ @@ -406,7 +393,7 @@ export class ChatRoomInviteService { const db = this.drizzle.db; await this.assertCanInvite(db, actorId, roomId, 'search', { ip, userAgent }); const now = new Date(); - const rows = await db + return db .select({ userId: user.id, username: user.username, @@ -416,21 +403,13 @@ export class ChatRoomInviteService { .from(user) .innerJoin(player, eq(player.userId, user.id)) .where( - and( - ne(user.id, actorId), - ilike(user.username, likeContains(q)), - visibleCandidate(db, roomId, now), - ), + and(ne(user.id, actorId), ilike(user.username, likeContains(q)), eq(user.role, 'player')), ) .orderBy( sql`case when ${user.username} ilike ${likePrefix(q)} then 0 else 1 end`, asc(user.username), ) .limit(limit); - const restricted = await this.restrictedAmong( - rows.filter((row) => row.inviteStatus === 'available').map((row) => row.userId), - ); - return rows.filter((row) => !restricted.has(row.userId)); } async getRoomInviteStatuses({ @@ -452,19 +431,15 @@ export class ChatRoomInviteService { .select({ userId: user.id, inviteStatus: inviteStatus(db, roomId, now) }) .from(user) .innerJoin(player, eq(player.userId, user.id)) - .where(and(inArray(user.id, ids), visibleCandidate(db, roomId, now))); - const restricted = await this.restrictedAmong( - rows.filter((row) => row.inviteStatus === 'available').map((row) => row.userId), - ); - const statuses = new Map( - rows - .filter((row) => !restricted.has(row.userId)) - .map((row) => [row.userId, row.inviteStatus]), - ); + .where(and(inArray(user.id, ids), eq(user.role, 'player'))); + const statuses = new Map(rows.map((row) => [row.userId, row.inviteStatus])); return ids.map((id) => ({ userId: id, inviteStatus: statuses.get(id) ?? 'unavailable' })); } async listMyRoomInvites(userId: Uuid) { + if (await this.isExcluded(userId)) { + return []; + } const db = this.drizzle.db; const rows = await db .select({ @@ -489,7 +464,7 @@ export class ChatRoomInviteService { .where( and( eq(chatRoomInvite.inviteeId, userId), - liveInvite(db, new Date()), + deliverableInvite(db, new Date()), notExists( db .select({ id: chatRoomMember.id }) @@ -518,7 +493,7 @@ export class ChatRoomInviteService { and( eq(chatRoomInvite.id, inviteId), eq(chatRoomInvite.inviteeId, userId), - liveInvite(db, now), + deliverableInvite(db, now), ); const [invite] = await this.drizzle.db .select({ roomId: chatRoomInvite.roomId, inviterId: chatRoomInvite.inviterId }) @@ -532,44 +507,16 @@ export class ChatRoomInviteService { } return room; } - const room = await this.membership.joinByInvite({ - roomId: invite.roomId, - userId, + if (await this.isExcluded(userId)) { + throw new ChatRoomInviteNotFoundError(inviteId); + } + const room = await this.joinByInvite(userId, inviteId, invite, addressedToCaller, { ip, userAgent, - inTransaction: async (t) => { - const now = new Date(); - const [locked] = await t - .select({ id: chatRoomInvite.id }) - .from(chatRoomInvite) - .where(addressedToCaller(t, now)) - .for('update', { of: chatRoomInvite }) - .limit(1); - if (!locked) { - throw new ChatRoomInviteNotFoundError(inviteId); - } - const [platformBan] = await t - .select({ id: chatPlatformBan.id }) - .from(chatPlatformBan) - .where(and(eq(chatPlatformBan.userId, userId), activePlatformBan(invite.roomId, now))) - .limit(1); - if (platformBan) { - throw new ChatRoomBannedError(invite.roomId); - } - const [block] = await t - .select({ blockerId: chatUserBlock.blockerId }) - .from(chatUserBlock) - .where(activeBlock(userId, invite.inviterId)) - .limit(1); - if (block) { - throw new ChatRoomInviterBlockedError(); - } - await t - .update(chatRoomInvite) - .set({ status: 'accepted', respondedAt: now }) - .where(eq(chatRoomInvite.id, inviteId)); - }, }); + if (!room.accepted) { + return room.room; + } this.events.emit('chat.room.invite.accepted', { inviteId, roomId: invite.roomId, @@ -579,7 +526,56 @@ export class ChatRoomInviteService { ip: ip ?? null, userAgent: userAgent ?? null, }); - return room; + return room.room; + } + + // A concurrent accept of the same invite finds it already accepted once it gets the room lock. + private async joinByInvite( + userId: Uuid, + inviteId: Uuid, + invite: { roomId: Uuid; inviterId: Uuid }, + addressedToCaller: (db: Db, now: Date) => SQL | undefined, + meta: ClientMeta, + ) { + try { + const room = await this.membership.joinByInvite({ + roomId: invite.roomId, + userId, + ...meta, + inTransaction: async (t) => { + const now = new Date(); + const [locked] = await t + .select({ id: chatRoomInvite.id }) + .from(chatRoomInvite) + .where(addressedToCaller(t, now)) + .for('update', { of: chatRoomInvite }) + .limit(1); + if (!locked) { + throw new ChatRoomInviteNotFoundError(inviteId); + } + if (await hasActivePlatformBan(t, userId, invite.roomId, now)) { + throw new ChatRoomBannedError(invite.roomId); + } + if (await hasActiveBlock(t, userId, invite.inviterId)) { + throw new ChatRoomInviterBlockedError(); + } + await t + .update(chatRoomInvite) + .set({ status: 'accepted', respondedAt: now }) + .where(eq(chatRoomInvite.id, inviteId)); + }, + }); + return { room, accepted: true } as const; + } catch (err) { + if (!(err instanceof ChatRoomInviteNotFoundError)) { + throw err; + } + const room = await this.acceptedRoom(userId, inviteId); + if (!room) { + throw err; + } + return { room, accepted: false } as const; + } } // A retried accept returns the room it already joined, while the invitee is still in it. @@ -613,7 +609,7 @@ export class ChatRoomInviteService { and( eq(chatRoomInvite.id, inviteId), eq(chatRoomInvite.inviteeId, userId), - liveInvite(db, now), + deliverableInvite(db, now), ), ) .returning({ roomId: chatRoomInvite.roomId, inviterId: chatRoomInvite.inviterId }); @@ -631,4 +627,28 @@ export class ChatRoomInviteService { }); return { success: true } as const; } + + async expireLapsedInvites() { + const db = this.drizzle.db; + let expired = 0; + let batch: ExpiredInvite[]; + do { + batch = await expirePendingInvites( + db, + inArray( + chatRoomInvite.id, + db + .select({ id: chatRoomInvite.id }) + .from(chatRoomInvite) + .where( + and(eq(chatRoomInvite.status, 'pending'), sql`not (${liveInvite(db, new Date())})`), + ) + .limit(INVITE_EXPIRY_BATCH_SIZE), + ), + ); + emitExpiredInvites(this.events, batch); + expired += batch.length; + } while (batch.length === INVITE_EXPIRY_BATCH_SIZE); + return expired; + } } diff --git a/packages/core/src/engagement/chat/service/chat-room-membership.service.ts b/packages/core/src/engagement/chat/service/chat-room-membership.service.ts index 3a433bb6e..68978b84d 100644 --- a/packages/core/src/engagement/chat/service/chat-room-membership.service.ts +++ b/packages/core/src/engagement/chat/service/chat-room-membership.service.ts @@ -19,6 +19,7 @@ import { chatRoom, chatRoomBan, chatRoomConfiguration, + chatRoomInvite, chatRoomMember, chatRoomRemove, } from '../schema/index.js'; @@ -37,6 +38,7 @@ import { } from '../contract/constants.js'; import { ChatRoomBannedError, + ChatRoomInviteOnlyError, ChatRoomJoinCodeNotFoundError, ChatRoomLastModeratorError, ChatRoomLockedError, @@ -47,6 +49,11 @@ import { ChatRoomSelfModerationError, } from './errors/chat-moderation.errors.js'; import { revokeChannelBestEffort, revokeRoomChannelBestEffort } from './channel-revoke.service.js'; +import { + emitExpiredInvites, + expireInvitesSentBy, + expirePendingInvites, +} from './chat-room-invite-expiry.service.js'; const MODERATOR_ROLES = ['moderator', 'owner'] as const; @@ -103,20 +110,29 @@ export class ChatRoomMembershipService { } if (!meta.adminId) { const [config] = await t - .select({ lockRoom: chatRoomConfiguration.lockRoom }) + .select({ + lockRoom: chatRoomConfiguration.lockRoom, + onlyInvitedCanJoin: chatRoomConfiguration.onlyInvitedCanJoin, + }) .from(chatRoomConfiguration) .where(eq(chatRoomConfiguration.roomId, roomId)) .limit(1); - const [member] = config?.lockRoom - ? await t - .select({ id: chatRoomMember.id }) - .from(chatRoomMember) - .where(and(eq(chatRoomMember.roomId, roomId), eq(chatRoomMember.userId, userId))) - .limit(1) - : []; + const inviteOnly = + !room.isPublic && config?.onlyInvitedCanJoin === true && !inTransaction; + const [member] = + config?.lockRoom || inviteOnly + ? await t + .select({ id: chatRoomMember.id }) + .from(chatRoomMember) + .where(and(eq(chatRoomMember.roomId, roomId), eq(chatRoomMember.userId, userId))) + .limit(1) + : []; if (config?.lockRoom && !member) { throw new ChatRoomLockedError(roomId); } + if (inviteOnly && !member) { + throw new ChatRoomInviteOnlyError(roomId); + } } const [ban] = await t .select({ id: chatRoomBan.id }) @@ -248,7 +264,7 @@ export class ChatRoomMembershipService { } async leaveRoom({ userId, roomId, ip, userAgent }: { userId: Uuid; roomId: Uuid } & ClientMeta) { - const { removed, isPublic } = await this.drizzle.db.transaction((t) => + const { removed, isPublic, expired } = await this.drizzle.db.transaction((t) => withAdvisoryXactLock(t, `chat-room:${roomId}`, async () => { const [room] = await t .select({ id: chatRoom.id, isPublic: chatRoom.isPublic }) @@ -290,14 +306,22 @@ export class ChatRoomMembershipService { .delete(chatRoomMember) .where(and(eq(chatRoomMember.roomId, roomId), eq(chatRoomMember.userId, userId))) .returning(); - return { removed: deleted, isPublic: room.isPublic }; + const expired = await expireInvitesSentBy(t, roomId, userId); + return { removed: deleted, isPublic: room.isPublic, expired }; }), ); + const playerId = await this.identityReader.getPlayerIdByUserIdSafe(userId); + emitExpiredInvites(this.events, expired, { + actorId: userId, + actorPlayerId: playerId, + ip, + userAgent, + }); if (removed.length > 0) { this.events.emit('chat.room.member.left', { roomId, userId, - playerId: await this.identityReader.getPlayerIdByUserIdSafe(userId), + playerId, ip: ip ?? null, userAgent: userAgent ?? null, }); @@ -320,7 +344,7 @@ export class ChatRoomMembershipService { if (moderatorId === userId) { throw new ChatRoomSelfModerationError(); } - const removed = await this.drizzle.db.transaction((t) => + const { removed, expired } = await this.drizzle.db.transaction((t) => withAdvisoryXactLock(t, `chat-room:${roomId}`, async () => { const [moderator] = await t .select({ role: chatRoomMember.role }) @@ -346,8 +370,9 @@ export class ChatRoomMembershipService { .where(and(eq(chatRoomMember.roomId, roomId), eq(chatRoomMember.userId, userId))) .returning({ id: chatRoomMember.id }); if (removed.length === 0) { - return false; + return { removed: false, expired: [] }; } + const expired = await expireInvitesSentBy(t, roomId, userId); const [removal] = await t .insert(chatRoomRemove) .values({ roomId, userId, removedBy: moderatorId, reason }) @@ -362,9 +387,16 @@ export class ChatRoomMembershipService { ip: ip ?? null, userAgent: userAgent ?? null, }); - return true; + return { removed: true, expired }; }), ); + emitExpiredInvites(this.events, expired, { + actorId: moderatorId, + actorPlayerId: + expired.length > 0 ? await this.identityReader.getPlayerIdByUserIdSafe(moderatorId) : null, + ip, + userAgent, + }); if (removed) { this.events.emit('chat.room.member.removed', { roomId, @@ -398,7 +430,7 @@ export class ChatRoomMembershipService { role: ChatRoomAssignableRole; } & ClientMeta) { // Same lock leaveRoom takes, so a role write serializes against the last-moderator check. - const changed = await this.drizzle.db.transaction((t) => + const { changed, expired } = await this.drizzle.db.transaction((t) => withAdvisoryXactLock(t, `chat-room:${roomId}`, async () => { const [room] = await t .select({ id: chatRoom.id }) @@ -436,16 +468,22 @@ export class ChatRoomMembershipService { throw new ChatRoomNotModeratorError(roomId); } if (target.role === role) { - return null; + return { changed: null, expired: [] }; } const updated = await t .update(chatRoomMember) .set({ role, roleAssignedAt: role === 'member' ? null : new Date() }) .where(and(eq(chatRoomMember.roomId, roomId), eq(chatRoomMember.userId, userId))) .returning({ id: chatRoomMember.id }); - return updated.length === 1 ? target.role : null; + if (updated.length !== 1) { + return { changed: null, expired: [] }; + } + const expired = role === 'member' ? await expireInvitesSentBy(t, roomId, userId) : []; + return { changed: target.role, expired }; }), ); + const playerId = changed ? await this.identityReader.getPlayerIdByUserIdSafe(actorId) : null; + emitExpiredInvites(this.events, expired, { actorId, actorPlayerId: playerId, ip, userAgent }); if (changed) { this.events.emit('chat.room.member.role-changed', { roomId, @@ -455,7 +493,7 @@ export class ChatRoomMembershipService { previousRole: changed, // Null when the acting owner has no player record (the audit mapper then falls back // to `changedBy`), matching how the kicked/banned events carry the actor. - playerId: await this.identityReader.getPlayerIdByUserIdSafe(actorId), + playerId, ip: ip ?? null, userAgent: userAgent ?? null, }); @@ -498,9 +536,22 @@ export class ChatRoomMembershipService { isNull(chatRoom.deletedAt), ), ); + const received = await expirePendingInvites( + this.drizzle.db, + eq(chatRoomInvite.inviteeId, userId), + ); + const playerId = + rooms.length > 0 || received.length > 0 + ? await this.identityReader.getPlayerIdByUserIdSafe(userId) + : null; + emitExpiredInvites(this.events, received, { actorId: userId, actorPlayerId: playerId }); for (const { id: roomId } of rooms) { const result = await this.closeAccountInRoom(roomId, userId, closedAt); if (result) { + emitExpiredInvites(this.events, result.expired, { + actorId: userId, + actorPlayerId: playerId, + }); await this.announceAccountClosed(roomId, userId, result.handover); } } @@ -522,10 +573,12 @@ export class ChatRoomMembershipService { ) .returning({ role: chatRoomMember.role }); if (!stamped) { - return this.rederiveHandover(t, roomId, userId, closedAt); + const rederived = await this.rederiveHandover(t, roomId, userId, closedAt); + return rederived && { ...rederived, expired: [] }; } + const expired = await expireInvitesSentBy(t, roomId, userId); if (stamped.role !== 'owner') { - return { handover: null }; + return { handover: null, expired }; } const [room] = await t .select({ name: chatRoom.name }) @@ -535,7 +588,7 @@ export class ChatRoomMembershipService { ) .limit(1); if (!room) { - return { handover: null }; + return { handover: null, expired }; } const candidates = await t .select({ userId: chatRoomMember.userId }) @@ -585,6 +638,7 @@ export class ChatRoomMembershipService { roomName: room.name, newOwnerId, }, + expired, } as const; } @@ -600,8 +654,9 @@ export class ChatRoomMembershipService { .returning({ scheduledDeletionAt: chatRoom.scheduledDeletionAt }); await demoteClosedOwner(); if (!scheduled?.scheduledDeletionAt) { - return { handover: null }; + return { handover: null, expired }; } + expired.push(...(await expirePendingInvites(t, eq(chatRoomInvite.roomId, roomId)))); const recipients = await t .select({ userId: chatRoomMember.userId }) .from(chatRoomMember) @@ -613,6 +668,7 @@ export class ChatRoomMembershipService { scheduledDeletionAt: scheduled.scheduledDeletionAt, memberIds: recipients.map((m) => m.userId), }, + expired, } as const; }), ); diff --git a/packages/core/src/engagement/chat/service/chat.service.ts b/packages/core/src/engagement/chat/service/chat.service.ts index d0205fcee..1f8ba3e29 100644 --- a/packages/core/src/engagement/chat/service/chat.service.ts +++ b/packages/core/src/engagement/chat/service/chat.service.ts @@ -61,8 +61,11 @@ import { chatRoomBan, chatRoomRule, chatRoomConfiguration, + chatRoomInvite, chatPlatformBan, } from '../schema/index.js'; +import { emitExpiredInvites, expirePendingInvites } from './chat-room-invite-expiry.service.js'; +import { emitAccessRefused } from './chat-access-refusal.service.js'; import { ChatMessageNotFoundError, ChatRoomNotFoundError } from './chat-moderation.service.js'; import { toMessage, toSystemMessage } from './chat-message-mapping.service.js'; export { @@ -1050,9 +1053,17 @@ export class ChatService { moderatorInvite?: boolean; } & ClientMeta) { await this.assertRoomModerator(roomId, actorId); - const config = await this.drizzle.db.transaction((tx) => + const updated = await this.drizzle.db.transaction((tx) => withAdvisoryXactLock(tx, `chat-room:${roomId}`, async () => { const before = await this.findRoomConfiguration(tx, roomId); + const moderatorInviteBefore = before?.moderatorInvite ?? false; + if ( + patch.moderatorInvite !== undefined && + patch.moderatorInvite !== moderatorInviteBefore && + (await this.roomRole(tx, roomId, actorId)) !== 'owner' + ) { + return null; + } const [after] = await tx .insert(chatRoomConfiguration) .values({ roomId, ...patch, updatedAt: new Date() }) @@ -1076,10 +1087,59 @@ export class ChatService { userAgent: userAgent ?? null, }); } - return after; + const expired = + moderatorInviteBefore && !after.moderatorInvite + ? await expirePendingInvites( + tx, + and( + eq(chatRoomInvite.roomId, roomId), + inArray( + chatRoomInvite.inviterId, + tx + .select({ userId: chatRoomMember.userId }) + .from(chatRoomMember) + .where( + and( + eq(chatRoomMember.roomId, roomId), + eq(chatRoomMember.role, 'moderator'), + ), + ), + ), + ), + ) + : []; + return { config: after, expired }; }), ); - return toConfiguration(config); + if (!updated) { + await emitAccessRefused(this.drizzle.db, this.events, { + actorId, + resource: 'chat.room_configuration', + action: 'update', + ip, + userAgent, + }); + throw new ChatRoomNotModeratorError(roomId); + } + emitExpiredInvites(this.events, updated.expired, { + actorId, + actorPlayerId: + updated.expired.length > 0 + ? await this.identityReader.getPlayerIdByUserIdSafe(actorId) + : null, + ip, + userAgent, + }); + return toConfiguration(updated.config); + } + + private async roomRole(db: DrizzleDb | DrizzleTx, roomId: ChatRoom['id'], userId: User['id']) { + const [member] = await db + .select({ role: chatRoomMember.role }) + .from(chatRoomMember) + .where(and(eq(chatRoomMember.roomId, roomId), eq(chatRoomMember.userId, userId))) + .limit(1); + return member?.role ?? null; } async listRoomUsers({ @@ -2095,18 +2155,29 @@ export class ChatService { .set({ deletedAt }) .where(and(eq(chatRoom.id, roomId), isNull(chatRoom.deletedAt))) .returning({ id: chatRoom.id }); - return updated.length === 1 ? { room, members } : null; + if (updated.length !== 1) { + return null; + } + const expired = await expirePendingInvites(t, eq(chatRoomInvite.roomId, roomId)); + return { room, members, expired }; }), ); if (!deleted) { throw new ChatRoomNotFoundError(roomId); } + const playerId = await this.identityReader.getPlayerIdByUserIdSafe(userId); + emitExpiredInvites(this.events, deleted.expired, { + actorId: userId, + actorPlayerId: playerId, + ip, + userAgent, + }); // Before the realtime cleanup: the deletion is committed, and an audit trail that depends // on a transport call succeeding would go missing exactly when the transport is down. this.events.emit('chat.private_room.deleted', { roomId, creatorId: userId, - playerId: await this.identityReader.getPlayerIdByUserIdSafe(userId), + playerId, before: { name: deleted.room.name, slug: deleted.room.slug, @@ -2137,19 +2208,25 @@ export class ChatService { if (existing.slug === GLOBAL_CHAT_ROOM_ID) { throw new ChatRoomProtectedError(); } - const deleted = findOneOrThrow( - await this.drizzle.db - .update(chatRoom) - .set({ deletedAt: new Date() }) - .where(and(eq(chatRoom.id, id), isNull(chatRoom.deletedAt))) - .returning({ - id: chatRoom.id, - name: chatRoom.name, - slug: chatRoom.slug, - category: chatRoom.category, - }), - new ChatRoomNotFoundError(id), + const { deleted, expired } = await this.drizzle.db.transaction((t) => + withAdvisoryXactLock(t, `chat-room:${id}`, async () => { + const deleted = findOneOrThrow( + await t + .update(chatRoom) + .set({ deletedAt: new Date() }) + .where(and(eq(chatRoom.id, id), isNull(chatRoom.deletedAt))) + .returning({ + id: chatRoom.id, + name: chatRoom.name, + slug: chatRoom.slug, + category: chatRoom.category, + }), + new ChatRoomNotFoundError(id), + ); + return { deleted, expired: await expirePendingInvites(t, eq(chatRoomInvite.roomId, id)) }; + }), ); + emitExpiredInvites(this.events, expired, { ...meta, actorId }); this.events.emit('chat.room.deleted', { roomId: id, actorId, diff --git a/packages/core/src/engagement/chat/service/errors/chat-moderation.errors.ts b/packages/core/src/engagement/chat/service/errors/chat-moderation.errors.ts index b35c065a3..78a0b92b1 100644 --- a/packages/core/src/engagement/chat/service/errors/chat-moderation.errors.ts +++ b/packages/core/src/engagement/chat/service/errors/chat-moderation.errors.ts @@ -76,3 +76,8 @@ export const ChatRoomLockedError = createDomainError( (roomId: Uuid) => `Room is locked: ${roomId}`, { reason: 'locked' }, ); +export const ChatRoomInviteOnlyError = createDomainError( + 'ChatRoomInviteOnlyError', + (roomId: Uuid) => `Room is invite-only: ${roomId}`, + { reason: 'invite_only' }, +); diff --git a/packages/core/src/engagement/chat/service/errors/chat-room-invite.errors.ts b/packages/core/src/engagement/chat/service/errors/chat-room-invite.errors.ts index 2c6d2ea09..ecfbf4d45 100644 --- a/packages/core/src/engagement/chat/service/errors/chat-room-invite.errors.ts +++ b/packages/core/src/engagement/chat/service/errors/chat-room-invite.errors.ts @@ -17,11 +17,6 @@ export const ChatRoomInviteeNotPlayerError = createDomainError( (userId: Uuid) => `Only players can be invited: ${userId}`, { reason: 'not_player' }, ); -export const ChatRoomInviteeUnavailableError = createDomainError( - 'ChatRoomInviteeUnavailableError', - (userId: Uuid) => `This player cannot be invited: ${userId}`, - { reason: 'unavailable' }, -); export const ChatRoomInviteeBannedError = createDomainError( 'ChatRoomInviteeBannedError', (userId: Uuid) => `This player is banned from the room: ${userId}`, diff --git a/packages/testing/src/__tests__/chat-room-invite-lifecycle.e2e.test.ts b/packages/testing/src/__tests__/chat-room-invite-lifecycle.e2e.test.ts new file mode 100644 index 000000000..cf99b9b59 --- /dev/null +++ b/packages/testing/src/__tests__/chat-room-invite-lifecycle.e2e.test.ts @@ -0,0 +1,485 @@ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { randomInt, randomUUID } from 'node:crypto'; +import { eq } from 'drizzle-orm'; +import { DRIZZLE, loadExtensions } from '@openora/core/server'; +import { JOB_QUEUE, PLAY_ELIGIBILITY, queue } from '@openora/core/contracts'; +import { user } from '@openora/core/pam/schema/identity'; +import { player } from '@openora/core/pam/schema/profile'; +import { chatRoomConfiguration, chatRoomInvite } from '@openora/core/engagement/schema/chat'; +import { + ChatRoomInviteSchema, + ChatRoomMemberSchema, + ChatRoomSchema, + MyChatRoomInviteSchema, + type ChatRoom, +} from '@openora/core/engagement/contracts/chat'; +import { + setupTestDb, + bootTestApp, + asAdmin, + registerAndMaterializePlayer, + seedMinimal, + type TestDb, + type TestApp, + type TestClient, +} from '../index.js'; + +let db: TestDb; +let app: TestApp; +let admin: TestClient; +let adminUserId: string; + +const WAIT = { timeout: 10_000, interval: 100 }; + +type Chatter = Awaited> & { username: string }; + +async function registerChatter(prefix: string): Promise { + const username = `${prefix.slice(0, 7)}_${randomInt(10 ** 9, 10 ** 10)}`; + const registered = await registerAndMaterializePlayer(app, { + email: `${username}@e2e.test`, + username, + }); + return { ...registered, username }; +} + +async function createRoom(owner: Chatter) { + const created = await owner.client.post('/chat/rooms/private', { name: `room-${randomUUID()}` }); + expect(created.status).toBe(200); + return ChatRoomSchema.parse(await created.json()); +} + +async function joinByCode(member: Chatter, room: ChatRoom) { + const joined = await member.client.post('/chat/rooms/join', { joinCode: room.joinCode }); + expect(joined.status).toBe(200); +} + +async function configureRoom( + owner: Chatter, + room: ChatRoom, + values: { moderatorInvite?: boolean; onlyInvitedCanJoin?: boolean }, +) { + const res = await owner.client.patch(`/chat/rooms/${room.id}/configuration`, values); + expect(res.status).toBe(200); +} + +async function addModerator(owner: Chatter, room: ChatRoom) { + const moderator = await registerChatter('keeper'); + await joinByCode(moderator, room); + const promoted = await owner.client.post( + `/chat/rooms/${room.id}/members/${moderator.userId}/role`, + { role: 'moderator' }, + ); + expect(promoted.status).toBe(200); + return moderator; +} + +function invite(actor: Chatter, room: ChatRoom, userId: string) { + return actor.client.post(`/chat/rooms/${room.id}/invites`, { userId }); +} + +async function sendInvite(actor: Chatter, room: ChatRoom, invitee: Chatter) { + const res = await invite(actor, room, invitee.userId); + expect(res.status).toBe(200); + return ChatRoomInviteSchema.parse(await res.json()); +} + +async function expectRejected(res: Response, status: number, reason: string) { + expect(res.status).toBe(status); + const body = (await res.json()) as { data?: { reason?: string } }; + expect(body.data?.reason).toBe(reason); +} + +async function myInvites(invitee: Chatter) { + const res = await invitee.client.get('/chat/invites'); + expect(res.status).toBe(200); + return MyChatRoomInviteSchema.array().parse(await res.json()); +} + +async function roomMemberIds(owner: Chatter, room: ChatRoom) { + const res = await owner.client.get(`/chat/rooms/${room.id}/members`); + expect(res.status).toBe(200); + return ChatRoomMemberSchema.array() + .parse(await res.json()) + .map((member) => member.userId); +} + +async function inviteRow(inviteId: string) { + const [row] = await app.container + .get(DRIZZLE) + .db.select() + .from(chatRoomInvite) + .where(eq(chatRoomInvite.id, inviteId)); + return row; +} + +async function auditRows(resourceId: string, action: string, actorId?: string) { + const res = await admin.get( + `/audit/logs?resourceId=${resourceId}&action=${action}${actorId ? `&actorId=${actorId}` : ''}`, + ); + expect(res.status).toBe(200); + return ((await res.json()) as { items: unknown[] }).items; +} + +async function expectExpiredBy(inviteId: string, actorType: 'player' | 'admin', actorId: string) { + await vi.waitFor(async () => { + expect(await inviteRow(inviteId)).toMatchObject({ status: 'expired', respondedAt: null }); + }, WAIT); + await vi.waitFor(async () => { + expect(await auditRows(inviteId, 'chat.room.invite.expired')).toMatchObject([ + { actorType, actorId, resourceType: 'chat_room_invite', result: 'success' }, + ]); + }, WAIT); +} + +async function inviteFromModerator(owner: Chatter, room: ChatRoom, invitee: Chatter) { + const moderator = await addModerator(owner, room); + await configureRoom(owner, room, { moderatorInvite: true }); + return { moderator, sent: await sendInvite(moderator, room, invitee) }; +} + +beforeAll(async () => { + process.env['BETTER_AUTH_SECRET'] ??= 'e2e-test-better-auth-secret-please-change-000000'; + process.env['AUTH_SECRET'] ??= process.env['BETTER_AUTH_SECRET']; + process.env['WITHDRAWAL_PIN_HMAC_SECRET'] ??= 'e2e-test-withdrawal-pin-hmac-secret-000000'; + process.env['NODE_ENV'] ??= 'test'; + + db = await setupTestDb(); + app = await bootTestApp({ plugins: await loadExtensions(), databaseUrl: db.url }); + await seedMinimal(app.container, { playerCount: 0 }); + admin = await asAdmin(app.app); + const [staff] = await app.container + .get(DRIZZLE) + .db.select({ id: user.id }) + .from(user) + .where(eq(user.email, 'admin@oss.dev')); + if (!staff) { + throw new Error('the seeded admin is missing'); + } + adminUserId = staff.id; +}, 60_000); + +afterAll(async () => { + await app?.close(); + await db?.dispose(); +}); + +describe('chat room invites: expiry in storage', () => { + it("expires a demoted moderator's invite for good", async () => { + const owner = await registerChatter('host'); + const invitee = await registerChatter('guest'); + const room = await createRoom(owner); + const moderator = await addModerator(owner, room); + await configureRoom(owner, room, { moderatorInvite: true }); + const sent = await sendInvite(moderator, room, invitee); + + const setRole = (role: 'member' | 'moderator') => + owner.client.post(`/chat/rooms/${room.id}/members/${moderator.userId}/role`, { role }); + expect((await setRole('member')).status).toBe(200); + expect(await inviteRow(sent.id)).toMatchObject({ status: 'expired', respondedAt: null }); + expect((await setRole('moderator')).status).toBe(200); + + expect(await inviteRow(sent.id)).toMatchObject({ status: 'expired' }); + expect(await myInvites(invitee)).toEqual([]); + expect((await invitee.client.post(`/chat/invites/${sent.id}/accept`)).status).toBe(404); + await expectExpiredBy(sent.id, 'player', owner.playerId); + }); + + it('sweeps an invite older than seven days into storage as expired', async () => { + const owner = await registerChatter('host'); + const invitee = await registerChatter('guest'); + const room = await createRoom(owner); + const stale = await sendInvite(owner, room, invitee); + const fresh = await sendInvite(owner, room, await registerChatter('guest')); + await app.container + .get(DRIZZLE) + .db.update(chatRoomInvite) + .set({ createdAt: new Date(Date.now() - 8 * 24 * 60 * 60 * 1000) }) + .where(eq(chatRoomInvite.id, stale.id)); + + await app.container.get(JOB_QUEUE).enqueue(queue('chat-room-invite-expiry'), {}); + + await vi.waitFor(async () => { + expect(await inviteRow(stale.id)).toMatchObject({ status: 'expired', respondedAt: null }); + }, WAIT); + expect(await inviteRow(fresh.id)).toMatchObject({ status: 'pending' }); + await vi.waitFor(async () => { + expect(await auditRows(stale.id, 'chat.room.invite.expired')).toMatchObject([ + { actorType: 'system', resourceType: 'chat_room_invite', result: 'success' }, + ]); + }, WAIT); + }); +}); + +describe('chat room invites: expiry when a party leaves the room', () => { + it("expires a moderator's invite when they leave the room", async () => { + const owner = await registerChatter('host'); + const room = await createRoom(owner); + const { moderator, sent } = await inviteFromModerator( + owner, + room, + await registerChatter('guest'), + ); + + expect((await moderator.client.post(`/chat/rooms/${room.id}/leave`)).status).toBe(200); + + await expectExpiredBy(sent.id, 'player', moderator.playerId); + }); + + it("expires a moderator's invite when the owner removes them", async () => { + const owner = await registerChatter('host'); + const room = await createRoom(owner); + const { moderator, sent } = await inviteFromModerator( + owner, + room, + await registerChatter('guest'), + ); + + const removed = await owner.client.post(`/chat/rooms/${room.id}/remove`, { + userId: moderator.userId, + }); + expect(removed.status).toBe(200); + + await expectExpiredBy(sent.id, 'player', owner.playerId); + }); + + it("expires a moderator's invite when the owner bans them from the room", async () => { + const owner = await registerChatter('host'); + const room = await createRoom(owner); + const { moderator, sent } = await inviteFromModerator( + owner, + room, + await registerChatter('guest'), + ); + + const banned = await owner.client.post(`/chat/rooms/${room.id}/ban`, { + userId: moderator.userId, + }); + expect(banned.status).toBe(200); + + await expectExpiredBy(sent.id, 'player', owner.playerId); + }); + + it("expires a moderator's invite once their account is closed", async () => { + const owner = await registerChatter('host'); + const invitee = await registerChatter('guest'); + const room = await createRoom(owner); + const { moderator, sent } = await inviteFromModerator(owner, room, invitee); + + expect((await admin.del(`/players/${moderator.playerId}`)).status).toBe(200); + + await expectExpiredBy(sent.id, 'player', moderator.playerId); + expect(await myInvites(invitee)).toEqual([]); + }); + + it("expires an invite once its invitee's account is closed", async () => { + const owner = await registerChatter('host'); + const invitee = await registerChatter('guest'); + const room = await createRoom(owner); + const sent = await sendInvite(owner, room, invitee); + + expect((await admin.del(`/players/${invitee.playerId}`)).status).toBe(200); + + await expectExpiredBy(sent.id, 'player', invitee.playerId); + }); + + it('expires an invite when its invitee is banned from the room', async () => { + const owner = await registerChatter('host'); + const invitee = await registerChatter('guest'); + const room = await createRoom(owner); + const sent = await sendInvite(owner, room, invitee); + + const banned = await owner.client.post(`/chat/rooms/${room.id}/ban`, { + userId: invitee.userId, + }); + expect(banned.status).toBe(200); + + await expectExpiredBy(sent.id, 'player', owner.playerId); + expect((await invitee.client.post(`/chat/invites/${sent.id}/accept`)).status).toBe(404); + }); + + it('expires an invite when its invitee gets a platform chat ban', async () => { + const owner = await registerChatter('host'); + const invitee = await registerChatter('guest'); + const room = await createRoom(owner); + const sent = await sendInvite(owner, room, invitee); + + const banned = await admin.post('/backoffice/chat/bans', { + userId: invitee.userId, + roomId: '__all', + reason: 'spam', + }); + expect(banned.status).toBe(200); + + await expectExpiredBy(sent.id, 'admin', adminUserId); + }); + + it('expires pending invites when the owner deletes the room', async () => { + const owner = await registerChatter('host'); + const room = await createRoom(owner); + const sent = await sendInvite(owner, room, await registerChatter('guest')); + + expect((await owner.client.del(`/chat/rooms/${room.id}`)).status).toBe(200); + + await expectExpiredBy(sent.id, 'player', owner.playerId); + }); + + it('expires pending invites when staff delete the room', async () => { + const owner = await registerChatter('host'); + const room = await createRoom(owner); + const sent = await sendInvite(owner, room, await registerChatter('guest')); + + expect((await admin.del(`/backoffice/chat/rooms/${room.id}`)).status).toBe(200); + + await expectExpiredBy(sent.id, 'admin', adminUserId); + }); +}); + +describe('chat room invites: eligibility after the invite', () => { + it('hides an invite once the invitee is self-excluded or restricted', async () => { + const owner = await registerChatter('host'); + const room = await createRoom(owner); + const selfExcluded = await registerChatter('excluded'); + const restricted = await registerChatter('resting'); + const toExcluded = await sendInvite(owner, room, selfExcluded); + const toRestricted = await sendInvite(owner, room, restricted); + expect((await myInvites(selfExcluded)).map((row) => row.id)).toEqual([toExcluded.id]); + expect((await myInvites(restricted)).map((row) => row.id)).toEqual([toRestricted.id]); + + const drizzle = app.container.get(DRIZZLE).db; + await drizzle + .update(player) + .set({ status: 'self_excluded' }) + .where(eq(player.userId, selfExcluded.userId)); + await drizzle.update(user).set({ rgBlocked: true }).where(eq(user.id, restricted.userId)); + expect(await app.container.get(PLAY_ELIGIBILITY).isRestricted(restricted.userId)).toBe(true); + + for (const [invitee, sent] of [ + [selfExcluded, toExcluded], + [restricted, toRestricted], + ] as const) { + expect(await myInvites(invitee)).toEqual([]); + expect((await invitee.client.post(`/chat/invites/${sent.id}/accept`)).status).toBe(404); + expect(await roomMemberIds(owner, room)).not.toContain(invitee.userId); + expect(await inviteRow(sent.id)).toMatchObject({ status: 'pending' }); + } + }); +}); + +describe('chat room invites: room settings', () => { + it('admits an invite-only room by invite and by staff, but not by code', async () => { + const owner = await registerChatter('host'); + const stranger = await registerChatter('stranger'); + const invitee = await registerChatter('guest'); + const room = await createRoom(owner); + await configureRoom(owner, room, { onlyInvitedCanJoin: true }); + + await expectRejected( + await stranger.client.post('/chat/rooms/join', { joinCode: room.joinCode }), + 403, + 'invite_only', + ); + const sent = await sendInvite(owner, room, invitee); + expect((await invitee.client.post(`/chat/invites/${sent.id}/accept`)).status).toBe(200); + expect((await admin.post(`/backoffice/chat/rooms/${room.id}/join`)).status).toBe(200); + + const members = await roomMemberIds(owner, room); + expect(members).toContain(invitee.userId); + expect(members).not.toContain(stranger.userId); + }); + + it('lets anyone join a public room that has invite-only switched on', async () => { + const created = await admin.post('/backoffice/chat/rooms', { + name: `public-${randomUUID()}`, + slug: `public-${randomUUID()}`, + category: 'games-sports', + }); + expect(created.status).toBe(200); + const room = ChatRoomSchema.parse(await created.json()); + await app.container + .get(DRIZZLE) + .db.update(chatRoomConfiguration) + .set({ onlyInvitedCanJoin: true }) + .where(eq(chatRoomConfiguration.roomId, room.id)); + const newcomer = await registerChatter('newcomer'); + + expect((await newcomer.client.post(`/chat/rooms/${room.id}/join`)).status).toBe(200); + }); + + it("audits a moderator's refused change to whether moderators may invite", async () => { + const owner = await registerChatter('host'); + const room = await createRoom(owner); + const moderator = await addModerator(owner, room); + + const refused = await moderator.client.patch(`/chat/rooms/${room.id}/configuration`, { + moderatorInvite: true, + }); + + expect(refused.status).toBe(403); + await vi.waitFor(async () => { + expect( + await auditRows( + 'chat.room_configuration:update', + 'identity.user.unauthorized_access', + moderator.playerId, + ), + ).toMatchObject([ + { + actorType: 'player', + actorId: moderator.playerId, + resourceType: 'chat.room_configuration', + result: 'failure', + }, + ]); + }, WAIT); + }); + + it('lets only the owner change whether moderators may invite', async () => { + const owner = await registerChatter('host'); + const room = await createRoom(owner); + const moderator = await addModerator(owner, room); + const patch = (actor: Chatter, values: Record) => + actor.client.patch(`/chat/rooms/${room.id}/configuration`, values); + + expect((await patch(moderator, { moderatorInvite: true })).status).toBe(403); + expect((await patch(moderator, { moderatorInvite: false })).status).toBe(200); + expect((await patch(owner, { moderatorInvite: true })).status).toBe(200); + expect((await patch(moderator, { moderatorInvite: true })).status).toBe(200); + expect((await patch(moderator, { moderatorInvite: false })).status).toBe(403); + }); +}); + +describe('chat room invites: concurrency', () => { + it('completes concurrent invites into one room', async () => { + const owner = await registerChatter('host'); + const room = await createRoom(owner); + const invitees = await Promise.all(Array.from({ length: 12 }, () => registerChatter('crowd'))); + + const responses = await Promise.all( + invitees.map((invitee) => invite(owner, room, invitee.userId)), + ); + + expect(responses.map((res) => res.status)).toEqual(invitees.map(() => 200)); + }); + + it('answers two concurrent accepts with the same room and accepts once', async () => { + const owner = await registerChatter('host'); + const invitee = await registerChatter('guest'); + const room = await createRoom(owner); + const sent = await sendInvite(owner, room, invitee); + + const responses = await Promise.all([ + invitee.client.post(`/chat/invites/${sent.id}/accept`), + invitee.client.post(`/chat/invites/${sent.id}/accept`), + ]); + + expect(responses.map((res) => res.status)).toEqual([200, 200]); + const rooms = await Promise.all( + responses.map(async (res) => ChatRoomSchema.parse(await res.json()).id), + ); + expect(rooms).toEqual([room.id, room.id]); + await vi.waitFor(async () => { + expect(await auditRows(sent.id, 'chat.room.invite.accepted')).toHaveLength(1); + }, WAIT); + expect(await auditRows(sent.id, 'chat.room.invite.accepted')).toHaveLength(1); + }); +}); diff --git a/packages/testing/src/__tests__/chat-room-invites.e2e.test.ts b/packages/testing/src/__tests__/chat-room-invites.e2e.test.ts index 53996b72b..84833e43a 100644 --- a/packages/testing/src/__tests__/chat-room-invites.e2e.test.ts +++ b/packages/testing/src/__tests__/chat-room-invites.e2e.test.ts @@ -2,10 +2,11 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { randomInt, randomUUID } from 'node:crypto'; import { and, eq } from 'drizzle-orm'; import { DRIZZLE, loadExtensions } from '@openora/core/server'; -import { CHAT_REALTIME_TRANSPORT, chatChannel } from '@openora/core/contracts'; +import { CHAT_REALTIME_TRANSPORT, PLAY_ELIGIBILITY, chatChannel } from '@openora/core/contracts'; import { user } from '@openora/core/pam/schema/identity'; import { player } from '@openora/core/pam/schema/profile'; -import { chatRoomInvite, chatRoomMember } from '@openora/core/engagement/schema/chat'; +import { chatRoomInvite } from '@openora/core/engagement/schema/chat'; +import { notification } from '@openora/core/engagement/schema/notifications'; import { CHAT_MEMBER_JOINED_SIGNAL, ChatRoomInviteCandidateSchema, @@ -58,7 +59,7 @@ async function joinByCode(member: Chatter, room: ChatRoom) { async function configureRoom( owner: Chatter, room: ChatRoom, - values: { moderatorInvite?: boolean; lockRoom?: boolean }, + values: { moderatorInvite?: boolean; lockRoom?: boolean; onlyInvitedCanJoin?: boolean }, ) { const res = await owner.client.patch(`/chat/rooms/${room.id}/configuration`, values); expect(res.status).toBe(200); @@ -114,6 +115,70 @@ async function inviteRow(inviteId: string) { return row; } +async function auditRows(resourceId: string, action: string) { + const res = await admin.get(`/audit/logs?resourceId=${resourceId}&action=${action}`); + expect(res.status).toBe(200); + return ((await res.json()) as { items: unknown[] }).items; +} + +async function inviteStatusOf(owner: Chatter, room: ChatRoom, invitee: Chatter) { + const res = await owner.client.post(`/chat/rooms/${room.id}/invite-statuses`, { + userIds: [invitee.userId], + }); + expect(res.status).toBe(200); + return ChatRoomInviteLookupSchema.array().parse(await res.json())[0]?.inviteStatus; +} + +async function expectWithheld(owner: Chatter, room: ChatRoom, invitee: Chatter) { + const res = await invite(owner, room, invitee.userId); + expect(res.status).toBe(200); + const body: unknown = await res.json(); + expect(body).not.toHaveProperty('withheld'); + const sent = ChatRoomInviteSchema.parse(body); + expect(sent).toMatchObject({ inviteeId: invitee.userId, status: 'pending' }); + expect(await inviteRow(sent.id)).toMatchObject({ status: 'pending', withheld: true }); + + expect(await inviteStatusOf(owner, room, invitee)).toBe('invited'); + await expectRejected(await invite(owner, room, invitee.userId), 409, 'invited'); + + await vi.waitFor(async () => { + expect(await auditRows(sent.id, 'chat.room.invite.withheld')).toMatchObject([ + { + actorType: 'player', + actorId: owner.playerId, + resourceType: 'chat_room_invite', + result: 'success', + }, + ]); + }, WAIT); + expect(await auditRows(sent.id, 'chat.room.invite.sent')).toEqual([]); + const notified = await app.container + .get(DRIZZLE) + .db.select({ id: notification.id }) + .from(notification) + .where( + and( + eq(notification.userId, invitee.userId), + eq(notification.type, 'chat.room_invite.received'), + ), + ); + expect(notified).toEqual([]); + return sent; +} + +async function expectHiddenFrom(invitee: Chatter, sent: { id: string }) { + expect(await myInvites(invitee)).toEqual([]); + expect((await invitee.client.post(`/chat/invites/${sent.id}/accept`)).status).toBe(404); + expect((await invitee.client.post(`/chat/invites/${sent.id}/decline`)).status).toBe(404); + expect(await inviteRow(sent.id)).toMatchObject({ status: 'pending' }); +} + +async function waitUntilRestricted(userId: string) { + await vi.waitFor(async () => { + expect(await app.container.get(PLAY_ELIGIBILITY).isRestricted(userId)).toBe(true); + }, WAIT); +} + async function watchRoomSignals(roomId: string) { const transport = app.container.get(CHAT_REALTIME_TRANSPORT); const probe = `probe-${randomUUID()}`; @@ -270,7 +335,7 @@ describe('chat room invites: rejections', () => { await expectRejected(await invite(owner, room, staff.userId), 400, 'not_player'); }); - it('refuses a player who blocked the inviter, without saying why', async () => { + it('withholds an invite to a player who blocked the inviter, without saying why', async () => { const owner = await registerChatter('host'); const blocker = await registerChatter('blocker'); const room = await createRoom(owner); @@ -278,10 +343,10 @@ describe('chat room invites: rejections', () => { 200, ); - await expectRejected(await invite(owner, room, blocker.userId), 403, 'unavailable'); + await expectHiddenFrom(blocker, await expectWithheld(owner, room, blocker)); }); - it('refuses a player under a responsible-gambling exclusion, without saying why', async () => { + it('withholds an invite to a player under a responsible-gambling exclusion', async () => { const owner = await registerChatter('host'); const room = await createRoom(owner); const coolingOff = await registerChatter('resting'); @@ -299,25 +364,20 @@ describe('chat room invites: rejections', () => { }); expect(excluded.status).toBe(200); - await vi.waitFor(async () => { - const res = await owner.client.post(`/chat/rooms/${room.id}/invite-statuses`, { - userIds: [coolingOff.userId, selfExcluded.userId], - }); - expect(ChatRoomInviteLookupSchema.array().parse(await res.json())).toEqual([ - { userId: coolingOff.userId, inviteStatus: 'unavailable' }, - { userId: selfExcluded.userId, inviteStatus: 'unavailable' }, - ]); - }, WAIT); for (const excludedPlayer of [coolingOff, selfExcluded]) { - await expectRejected(await invite(owner, room, excludedPlayer.userId), 403, 'unavailable'); + await waitUntilRestricted(excludedPlayer.userId); + expect(await inviteStatusOf(owner, room, excludedPlayer)).toBe('available'); const search = await owner.client.get( `/chat/rooms/${room.id}/invite-candidates?q=${excludedPlayer.username}`, ); - expect(ChatRoomInviteCandidateSchema.array().parse(await search.json())).toEqual([]); + expect(ChatRoomInviteCandidateSchema.array().parse(await search.json())).toMatchObject([ + { userId: excludedPlayer.userId, inviteStatus: 'available' }, + ]); + await expectWithheld(owner, room, excludedPlayer); } }); - it('refuses a suspended player', async () => { + it('withholds an invite to a suspended player', async () => { const owner = await registerChatter('host'); const suspended = await registerChatter('susp'); const room = await createRoom(owner); @@ -327,7 +387,8 @@ describe('chat room invites: rejections', () => { .set({ status: 'suspended' }) .where(eq(player.userId, suspended.userId)); - await expectRejected(await invite(owner, room, suspended.userId), 403, 'unavailable'); + expect(await inviteStatusOf(owner, room, suspended)).toBe('available'); + await expectHiddenFrom(suspended, await expectWithheld(owner, room, suspended)); }); it('refuses a player banned from the room', async () => { @@ -344,7 +405,7 @@ describe('chat room invites: rejections', () => { await expectRejected(await invite(owner, room, banned.userId), 403, 'banned'); }); - it('refuses a player under a platform chat ban', async () => { + it('withholds an invite to a player under a platform chat ban', async () => { const owner = await registerChatter('host'); const banned = await registerChatter('banned'); const room = await createRoom(owner); @@ -355,7 +416,7 @@ describe('chat room invites: rejections', () => { }); expect(ban.status).toBe(200); - await expectRejected(await invite(owner, room, banned.userId), 403, 'unavailable'); + await expectHiddenFrom(banned, await expectWithheld(owner, room, banned)); }); it('refuses a player who is already a member', async () => { @@ -624,7 +685,8 @@ describe('chat room invites: accepting', () => { }); expect(ban.status).toBe(200); - expect((await invitee.client.post(`/chat/invites/${sent.id}/accept`)).status).toBe(403); + expect((await invitee.client.post(`/chat/invites/${sent.id}/accept`)).status).toBe(404); + expect(await inviteRow(sent.id)).toMatchObject({ status: 'expired' }); expect(await roomMemberIds(owner, room)).not.toContain(invitee.userId); }); }); @@ -657,6 +719,7 @@ describe('chat room invites: declining and expiry', () => { const sent = await sendInvite(moderator, room, invitee); await configureRoom(owner, room, { moderatorInvite: false }); + expect(await inviteRow(sent.id)).toMatchObject({ status: 'expired' }); expect(await myInvites(invitee)).toEqual([]); expect((await invitee.client.post(`/chat/invites/${sent.id}/accept`)).status).toBe(404); expect(await roomMemberIds(owner, room)).not.toContain(invitee.userId); @@ -694,32 +757,17 @@ describe('chat room invites: declining and expiry', () => { ); expect(auditRes.status).toBe(200); const { items } = (await auditRes.json()) as { items: unknown[] }; - expect(items[0]).toMatchObject({ - actorType: 'system', - resourceType: 'chat_room_invite', - result: 'success', - }); + expect(items).toMatchObject([ + { + actorType: 'player', + actorId: owner.playerId, + resourceType: 'chat_room_invite', + result: 'success', + }, + ]); }, WAIT); }); - it("voids an invite once the inviting moderator's account is closed", async () => { - const owner = await registerChatter('host'); - const invitee = await registerChatter('guest'); - const room = await createRoom(owner); - const moderator = await addModerator(owner, room); - await configureRoom(owner, room, { moderatorInvite: true }); - const sent = await sendInvite(moderator, room, invitee); - await app.container - .get(DRIZZLE) - .db.update(chatRoomMember) - .set({ accountClosedAt: new Date() }) - .where(and(eq(chatRoomMember.roomId, room.id), eq(chatRoomMember.userId, moderator.userId))); - - expect(await myInvites(invitee)).toEqual([]); - expect((await invitee.client.post(`/chat/invites/${sent.id}/accept`)).status).toBe(404); - expect(await roomMemberIds(owner, room)).not.toContain(invitee.userId); - }); - it('voids an invite once the inviter is put under a platform chat ban', async () => { const owner = await registerChatter('host'); const invitee = await registerChatter('guest'); @@ -732,6 +780,7 @@ describe('chat room invites: declining and expiry', () => { }); expect(ban.status).toBe(200); + expect(await inviteRow(sent.id)).toMatchObject({ status: 'expired' }); expect(await myInvites(invitee)).toEqual([]); expect((await invitee.client.post(`/chat/invites/${sent.id}/accept`)).status).toBe(404); }); @@ -818,7 +867,7 @@ describe('chat room invites: finding players', () => { ]); }); - it('reports a player under a platform chat ban as unavailable', async () => { + it('reports a player under a platform chat ban as available', async () => { const owner = await registerChatter('host'); const room = await createRoom(owner); const banned = await registerChatter('banned'); @@ -833,7 +882,7 @@ describe('chat room invites: finding players', () => { userIds: [banned.userId], }); expect(ChatRoomInviteLookupSchema.array().parse(await res.json())).toEqual([ - { userId: banned.userId, inviteStatus: 'unavailable' }, + { userId: banned.userId, inviteStatus: 'available' }, ]); });