diff --git a/packages/core/src/audit/__tests__/map-event.test.ts b/packages/core/src/audit/__tests__/map-event.test.ts index 47dbf7c1..2a437115 100644 --- a/packages/core/src/audit/__tests__/map-event.test.ts +++ b/packages/core/src/audit/__tests__/map-event.test.ts @@ -1092,3 +1092,113 @@ describe('mapEventToRecord: game review', () => { }); }); }); + +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, + 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', + actorId: null, + action: 'chat.room.invite.expired', + resourceType: 'chat_room_invite', + resourceId: inviteId, + result: 'success', + after: { roomId, inviterId, inviteeId: userId, status: 'expired' }, + }); + }); + + 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, + roomId, + inviterId, + inviteeId: userId, + inviterUsername: 'host', + roomName: 'room', + playerId: null, + }); + + 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 01115006..7c7fc555 100644 --- a/packages/core/src/audit/plugin.ts +++ b/packages/core/src/audit/plugin.ts @@ -470,6 +470,59 @@ 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' || 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', + ...(topic === 'chat.room.invite.withheld' ? { withheld: true } : {}), + }, + }; + } + + 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']), + result: 'success', + after: { + roomId: str(p['roomId']), + inviterId: str(p['inviterId']), + status: topic === 'chat.room.invite.accepted' ? 'accepted' : 'declined', + }, + }; + } + + 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: { + 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. @@ -1361,6 +1414,11 @@ const SUBSCRIBED_TOPICS: DomainEventName[] = [ 'chat.room.deleted', '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', '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 ac931260..a334fd7c 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 56b745ac..1a1301b5 100644 --- a/packages/core/src/contracts/schemas/events.ts +++ b/packages/core/src/contracts/schemas/events.ts @@ -883,6 +883,48 @@ 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(), + }), + // 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, + inviterId: UuidSchema, + inviteeId: UuidSchema, + playerId: UuidSchema.nullable(), + }), + 'chat.room.invite.declined': authContextBase.extend({ + inviteId: UuidSchema, + roomId: UuidSchema, + inviterId: UuidSchema, + inviteeId: UuidSchema, + playerId: UuidSchema.nullable(), + }), + // 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, userId: 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 f8f679ba..63234206 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 @@ -97,7 +97,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 1825aa6f..c030fb70 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 a4450f5d..c71f7826 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 @@ -131,7 +131,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, @@ -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/__tests__/global-chat-room.int.test.ts b/packages/core/src/engagement/chat/__tests__/global-chat-room.int.test.ts index 84428904..3548a16e 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 489ca627..7bf23f0f 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 bee60fed..234ccb11 100644 --- a/packages/core/src/engagement/chat/contract/constants.ts +++ b/packages/core/src/engagement/chat/contract/constants.ts @@ -34,6 +34,33 @@ 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', 'expired'] as const; + +export const CHAT_ROOM_INVITE_CANDIDATE_STATUSES = [ + 'available', + 'member', + 'invited', + 'banned', +] as const; + +// `unavailable` marks an id that is not a player. +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_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 34b4e474..deae3091 100644 --- a/packages/core/src/engagement/chat/contract/index.ts +++ b/packages/core/src/engagement/chat/contract/index.ts @@ -33,6 +33,13 @@ 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_MIN_LENGTH, + ROOM_INVITE_SEARCH_MAX_LENGTH, ROOM_ACTIVITY_WINDOW_HOURS_DEFAULT, ROOM_ACTIVITY_WINDOW_HOURS_MAX, CHAT_ROOM_RESTRICTION_TYPES, @@ -125,6 +132,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(), @@ -406,6 +416,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(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), +}); + +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)), @@ -551,6 +618,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/0019_chat_room_invite.sql b/packages/core/src/engagement/chat/drizzle/migrations/0019_chat_room_invite.sql new file mode 100644 index 00000000..466d58a8 --- /dev/null +++ b/packages/core/src/engagement/chat/drizzle/migrations/0019_chat_room_invite.sql @@ -0,0 +1,17 @@ +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, + "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 +); +--> 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_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/0019_snapshot.json b/packages/core/src/engagement/chat/drizzle/migrations/meta/0019_snapshot.json new file mode 100644 index 00000000..344b9f37 --- /dev/null +++ b/packages/core/src/engagement/chat/drizzle/migrations/meta/0019_snapshot.json @@ -0,0 +1,2061 @@ +{ + "id": "14b77064-2207-45ea-a41a-c643603d1ae7", + "prevId": "5096c9ce-5a80-452f-9e28-dede08507106", + "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": {} + }, + "chat_mute_active_room_idx": { + "name": "chat_mute_active_room_idx", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat_mute\".\"lifted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_mute_active_scope_idx": { + "name": "chat_mute_active_scope_idx", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat_mute\".\"lifted_at\" IS NULL", + "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": {} + }, + "chat_platform_ban_active_room_idx": { + "name": "chat_platform_ban_active_room_idx", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat_platform_ban\".\"lifted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_platform_ban_active_scope_idx": { + "name": "chat_platform_ban_active_scope_idx", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat_platform_ban\".\"lifted_at\" IS NULL", + "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_player_cooldown": { + "name": "chat_player_cooldown", + "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 + }, + "cooldown_seconds": { + "name": "cooldown_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_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 + }, + "expiry_recorded_at": { + "name": "expiry_recorded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chat_player_cooldown_active_scope_key": { + "name": "chat_player_cooldown_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_player_cooldown\".\"lifted_at\" IS NULL AND \"chat_player_cooldown\".\"room_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_player_cooldown_active_room_key": { + "name": "chat_player_cooldown_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_player_cooldown\".\"lifted_at\" IS NULL AND \"chat_player_cooldown\".\"room_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_player_cooldown_user_idx": { + "name": "chat_player_cooldown_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_player_cooldown_active_room_idx": { + "name": "chat_player_cooldown_active_room_idx", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat_player_cooldown\".\"lifted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_player_cooldown_active_scope_idx": { + "name": "chat_player_cooldown_active_scope_idx", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat_player_cooldown\".\"lifted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_player_cooldown_expiry_due_idx": { + "name": "chat_player_cooldown_expiry_due_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat_player_cooldown\".\"expires_at\" IS NOT NULL AND \"chat_player_cooldown\".\"expiry_recorded_at\" IS NULL AND (\"chat_player_cooldown\".\"lifted_at\" IS NULL OR \"chat_player_cooldown\".\"lifted_at\" = \"chat_player_cooldown\".\"expires_at\")", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "chat_player_cooldown_seconds_check": { + "name": "chat_player_cooldown_seconds_check", + "value": "\"chat_player_cooldown\".\"cooldown_seconds\" > 0 AND \"chat_player_cooldown\".\"cooldown_seconds\" <= 86400" + }, + "chat_player_cooldown_room_scope_check": { + "name": "chat_player_cooldown_room_scope_check", + "value": "(\"chat_player_cooldown\".\"scope\" = 'room') = (\"chat_player_cooldown\".\"room_id\" IS NOT NULL)" + } + }, + "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'" + }, + "withheld": { + "name": "withheld", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "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_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": [ + { + "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", "expired"] + }, + "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 4fbd1321..877feb42 100644 --- a/packages/core/src/engagement/chat/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/engagement/chat/drizzle/migrations/meta/_journal.json @@ -134,6 +134,13 @@ "when": 1791375545203, "tag": "0018_chat_restriction_room_indexes", "breakpoints": true + }, + { + "idx": 19, + "version": "7", + "when": 1791569167248, + "tag": "0019_chat_room_invite", + "breakpoints": true } ] } diff --git a/packages/core/src/engagement/chat/plugin.ts b/packages/core/src/engagement/chat/plugin.ts index f1b972c8..0618137e 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'; @@ -30,6 +31,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 { ChatRoomRestrictionService } from './service/chat-room-restriction.service.js'; @@ -42,12 +44,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) => @@ -65,6 +70,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( @@ -74,6 +80,7 @@ export default { c.get(DRIZZLE), c.get(CHAT_REALTIME_TRANSPORT), c.get(AUDIT_WRITER), + c.get(EVENT_BUS), ), ); const createChatService = ( @@ -133,6 +140,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; @@ -318,9 +340,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: 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 61d5883e..0fe65417 100644 --- a/packages/core/src/engagement/chat/router/index.ts +++ b/packages/core/src/engagement/chat/router/index.ts @@ -49,10 +49,22 @@ import { ChatPlayerMutedError, ChatPlayerBannedError, ChatAdminPrivateRoomModerationError, + ChatRoomInviteOnlyError, ChatRoomLockedError, 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, + 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'; import { ChatRoomRestrictionService } from '../service/chat-room-restriction.service.js'; @@ -64,6 +76,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: 'deny', +} as const; + const SEND_MESSAGE_RATE_LIMIT = { limit: 10, windowMs: 10_000, @@ -101,6 +125,7 @@ async function* messagesOf( export function createChatRouter({ chatService, membershipService, + inviteService, roomBanService, roomMuteService, restrictionService, @@ -111,6 +136,7 @@ export function createChatRouter({ }: { chatService: ChatService; membershipService: ChatRoomMembershipService; + inviteService: ChatRoomInviteService; roomBanService: ChatRoomBanService; roomMuteService: ChatRoomMuteService; restrictionService: ChatRoomRestrictionService; @@ -373,7 +399,7 @@ export function createChatRouter({ return mapErrors( { NOT_FOUND: ChatRoomJoinCodeNotFoundError, - FORBIDDEN: [ChatRoomBannedError, ChatRoomLockedError], + FORBIDDEN: [ChatRoomBannedError, ChatRoomLockedError, ChatRoomInviteOnlyError], }, () => membershipService.joinRoom({ userId, joinCode: input.joinCode, ...context.clientMeta }), @@ -388,7 +414,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 }), ); @@ -413,6 +442,99 @@ 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, + 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, + ...context.clientMeta, + }), + ); + }, + ), + + 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, ...context.clientMeta }), + ); + }), + + 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 dd767468..c78f576b 100644 --- a/packages/core/src/engagement/chat/schema/index.ts +++ b/packages/core/src/engagement/chat/schema/index.ts @@ -15,6 +15,7 @@ import { import { CHAT_ROOM_CATEGORIES, CHAT_ROOM_ROLES, + CHAT_ROOM_INVITE_STATUSES, CHAT_MODERATION_SCOPE_VALUES, CHAT_COOLDOWN_SECONDS_MAX, } from '../contract/index.js'; @@ -26,6 +27,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', @@ -145,6 +147,32 @@ 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'), + withheld: boolean().notNull().default(false), + 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_inviter_pending_idx') + .on(t.inviterId) + .where(sql`${t.status} = 'pending'`), + index('chat_room_invite_room_idx').on(t.roomId), + ], +); + export const chatRoomRule = pgTable( 'chat_room_rule', { @@ -367,6 +395,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-access-refusal.service.ts b/packages/core/src/engagement/chat/service/chat-access-refusal.service.ts new file mode 100644 index 00000000..db98d6ac --- /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 2ff5e152..3941dadd 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 89a6137b..aa63082c 100644 --- a/packages/core/src/engagement/chat/service/chat-moderation.service.ts +++ b/packages/core/src/engagement/chat/service/chat-moderation.service.ts @@ -13,6 +13,7 @@ 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; @@ -20,10 +21,15 @@ export class ChatModerationService implements ChatModeration { private readonly bans: ChatBanService; private readonly cooldowns: ChatCooldownService; - 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); this.cooldowns = new ChatCooldownService(drizzle, audit); } 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 695177da..fb1d8bf8 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 00000000..3b8db45d --- /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 new file mode 100644 index 00000000..36f67909 --- /dev/null +++ b/packages/core/src/engagement/chat/service/chat-room-invite.service.ts @@ -0,0 +1,654 @@ +import { + and, + asc, + desc, + eq, + exists, + gt, + ilike, + inArray, + isNull, + ne, + notExists, + or, + sql, + type SQL, +} from 'drizzle-orm'; +import { + DrizzleService, + likeContains, + likePrefix, + serializeRow, + withAdvisoryXactLock, + withAdvisoryXactLocks, +} from '@openora/core/server'; +import type { DrizzleDb, DrizzleTx, EventBus } from '@openora/core/server'; +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 { + 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, + 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 EXCLUDED_PLAYER_STATUSES = ['self_excluded', 'suspended', 'closed'] as const; + +const INVITE_EXPIRY_BATCH_SIZE = 500; + +const INVITE_AUDIT_RESOURCE = 'chat.room_invite'; + +type InviteAction = 'create' | 'search' | 'lookup'; + +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, 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 }) + .from(chatRoomMember) + .leftJoin(chatRoomConfiguration, eq(chatRoomConfiguration.roomId, chatRoomMember.roomId)) + .where( + 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), + ), + ), + ), + ); +} + +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), + isNull(chatRoomBan.liftedAt), + or(isNull(chatRoomBan.expiresAt), gt(chatRoomBan.expiresAt, now)), + ); +} + +function activePlatformBan(roomId: Uuid | typeof chatRoomInvite.roomId, 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)), + ), + or(isNull(chatPlatformBan.roomId), eq(chatPlatformBan.roomId, roomId)), + ); +} + +function activeBlock(blockerId: Uuid, blockedId: Uuid) { + return and( + eq(chatUserBlock.blockerId, blockerId), + eq(chatUserBlock.blockedId, blockedId), + isNull(chatUserBlock.removedAt), + ); +} + +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), + isNull(chatRoom.deletedAt), + isNull(chatRoom.scheduledDeletionAt), + ); +} + +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))); + const roomBan = db + .select({ id: chatRoomBan.id }) + .from(chatRoomBan) + .where(and(activeRoomBan(roomId, now), eq(chatRoomBan.userId, user.id))); + const pendingInvite = db + .select({ id: chatRoomInvite.id }) + .from(chatRoomInvite) + .where( + and( + eq(chatRoomInvite.roomId, roomId), + eq(chatRoomInvite.inviteeId, user.id), + liveInvite(db, now), + ), + ); + return sql`case + when ${exists(member)} then 'member' + when ${exists(roomBan)} then 'banned' + when ${exists(pendingInvite)} then 'invited' + else 'available' end`; +} + +export class ChatRoomInviteService { + constructor( + private readonly drizzle: DrizzleService, + private readonly events: EventBus, + private readonly identityReader: IdentityReader, + 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, + action: InviteAction, + meta: ClientMeta, + ) { + 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?.canInvite) { + await emitAccessRefused(db, this.events, { + actorId, + resource: INVITE_AUDIT_RESOURCE, + action, + ...meta, + }); + throw room ? new ChatRoomInviteForbiddenError(roomId) : new ChatRoomNotFoundError(roomId); + } + return { + name: room.name, + inviterUsername: room.inviterUsername, + lockRoom: room.lockRoom === true, + }; + } + + private async isRestricted(userId: Uuid) { + return (await this.playEligibility?.isRestricted(userId)) ?? false; + } + + private async isExcluded(userId: Uuid) { + if (await this.isRestricted(userId)) { + return true; + } + 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({ + actorId, + roomId, + userId, + ip, + userAgent, + }: { actorId: Uuid; roomId: Uuid; userId: Uuid } & ClientMeta) { + 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}`, () => + 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', + ), + ), + ); + 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, + playerId, + ip: ip ?? null, + userAgent: userAgent ?? null, + }; + 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({ + actorId, + roomId, + q, + limit, + ip, + userAgent, + }: { + actorId: Uuid; + roomId: Uuid; + q: string; + limit: number; + } & ClientMeta): Promise { + const db = this.drizzle.db; + await this.assertCanInvite(db, actorId, roomId, 'search', { ip, userAgent }); + const now = new Date(); + return db + .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)), eq(user.role, 'player')), + ) + .orderBy( + sql`case when ${user.username} ilike ${likePrefix(q)} then 0 else 1 end`, + asc(user.username), + ) + .limit(limit); + } + + async getRoomInviteStatuses({ + actorId, + roomId, + userIds, + ip, + userAgent, + }: { + actorId: Uuid; + roomId: Uuid; + userIds: Uuid[]; + } & ClientMeta): Promise { + const db = this.drizzle.db; + await this.assertCanInvite(db, actorId, roomId, 'lookup', { ip, userAgent }); + const ids = [...new Set(userIds)]; + 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), 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({ + 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), + deliverableInvite(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), + deliverableInvite(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) { + const room = await this.acceptedRoom(userId, inviteId); + if (!room) { + throw new ChatRoomInviteNotFoundError(inviteId); + } + return room; + } + if (await this.isExcluded(userId)) { + throw new ChatRoomInviteNotFoundError(inviteId); + } + const room = await this.joinByInvite(userId, inviteId, invite, addressedToCaller, { + ip, + userAgent, + }); + if (!room.accepted) { + return room.room; + } + 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.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. + 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, + 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), + deliverableInvite(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; + } + + 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 487a86ec..68978b84 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,26 +14,31 @@ import { type RealtimeTransport, type Uuid, } from '@openora/core/contracts'; +import { user } from '@openora/core/pam/schema/identity'; import { chatRoom, chatRoomBan, chatRoomConfiguration, + chatRoomInvite, chatRoomMember, 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, } from '../contract/constants.js'; import { ChatRoomBannedError, + ChatRoomInviteOnlyError, ChatRoomJoinCodeNotFoundError, ChatRoomLastModeratorError, ChatRoomLockedError, @@ -44,9 +49,16 @@ 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; +const STAFF_USER_ROLES = ['admin', 'super-admin'] as const; + const DAY_MS = 24 * 60 * 60 * 1000; const logger = createLogger('chat'); @@ -77,13 +89,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) { @@ -91,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 }) @@ -121,12 +149,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 +171,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 (!room.isPublic && !STAFF_USER_ROLES.some((role) => role === joinerRole)) { + await this.signalMemberJoined(roomId, userId); + } } return toRoom(room); } @@ -152,39 +189,82 @@ 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, + }); + } + + 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( + 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) { - 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 }) @@ -226,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, }); @@ -256,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 }) @@ -282,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 }) @@ -298,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, @@ -334,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 }) @@ -372,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, @@ -391,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, }); @@ -404,6 +506,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 { @@ -425,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); } } @@ -449,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 }) @@ -462,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 }) @@ -512,6 +638,7 @@ export class ChatRoomMembershipService { roomName: room.name, newOwnerId, }, + expired, } as const; } @@ -527,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) @@ -540,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 d99359ea..63ee4774 100644 --- a/packages/core/src/engagement/chat/service/chat.service.ts +++ b/packages/core/src/engagement/chat/service/chat.service.ts @@ -67,8 +67,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 { @@ -1238,17 +1241,69 @@ export class ChatService { moderatorInvite?: boolean; } & ClientMeta) { await this.assertRoomModerator(roomId, actorId); - const config = await this.drizzle.db.transaction((tx) => - this.upsertRoomConfiguration(tx, { - roomId, + 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 this.upsertRoomConfiguration(tx, { + roomId, + actorId, + actorType: 'player', + patch, + ip, + userAgent, + }); + 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 }; + }), + ); + if (!updated) { + await emitAccessRefused(this.drizzle.db, this.events, { actorId, - actorType: 'player', - patch, + resource: 'chat.room_configuration', + action: 'update', ip, userAgent, - }), - ); - return toConfiguration(config); + }); + 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); } async adminUpdateRoomConfiguration({ @@ -1316,6 +1371,15 @@ export class ChatService { }); } + 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({ roomId, actorId, @@ -2411,18 +2475,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, @@ -2453,19 +2528,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 56d7067e..10d5ee33 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 @@ -83,3 +83,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 new file mode 100644 index 00000000..ecfbf4d4 --- /dev/null +++ b/packages/core/src/engagement/chat/service/errors/chat-room-invite.errors.ts @@ -0,0 +1,39 @@ +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 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 67fefd9a..22b67ad5 100644 --- a/packages/core/src/engagement/notifications/contract/index.ts +++ b/packages/core/src/engagement/notifications/contract/index.ts @@ -29,6 +29,7 @@ export const NOTIFICATION_TYPES = [ 'chat.mention', 'chat.room.ownership_transferred', 'chat.room.scheduled_for_deletion', + 'chat.room_invite.received', // Core never emits this one: it is the seam for an operator-run VIP programme, whose overlay // tells the player through its own NotificationsService when it admits them. 'vip.assigned', diff --git a/packages/core/src/engagement/notifications/plugin.ts b/packages/core/src/engagement/notifications/plugin.ts index 629f8b32..53167ff0 100644 --- a/packages/core/src/engagement/notifications/plugin.ts +++ b/packages/core/src/engagement/notifications/plugin.ts @@ -435,6 +435,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 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. mapEvent('social.friend_request.sent', (p) => ({ userId: p.addresseeId, 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 00000000..cf99b9b5 --- /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 new file mode 100644 index 00000000..84833e43 --- /dev/null +++ b/packages/testing/src/__tests__/chat-room-invites.e2e.test.ts @@ -0,0 +1,932 @@ +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, 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 } from '@openora/core/engagement/schema/chat'; +import { notification } from '@openora/core/engagement/schema/notifications'; +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; 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) { + 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()}`; + 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('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'); + 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=gue`)).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('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); + expect((await blocker.client.post('/chat/blocks', { blockedId: owner.userId })).status).toBe( + 200, + ); + + await expectHiddenFrom(blocker, await expectWithheld(owner, room, blocker)); + }); + + 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'); + 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); + + for (const excludedPlayer of [coolingOff, selfExcluded]) { + 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())).toMatchObject([ + { userId: excludedPlayer.userId, inviteStatus: 'available' }, + ]); + await expectWithheld(owner, room, excludedPlayer); + } + }); + + it('withholds an invite to 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)); + + 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 () => { + 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('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); + const ban = await admin.post('/backoffice/chat/bans', { + userId: banned.userId, + roomId: '__all', + reason: 'spam', + }); + expect(ban.status).toBe(200); + + await expectHiddenFrom(banned, await expectWithheld(owner, room, banned)); + }); + + 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 a private room`, + data: { + inviteId: sent.id, + roomId: room.id, + inviterId: owner.userId, + roomName: room.name, + }, + }); + }, 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('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'); + 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(404); + expect(await inviteRow(sent.id)).toMatchObject({ status: 'expired' }); + 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 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); + 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]); + 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).toMatchObject([ + { + actorType: 'player', + actorId: owner.playerId, + resourceType: 'chat_room_invite', + result: 'success', + }, + ]); + }, WAIT); + }); + + 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 inviteRow(sent.id)).toMatchObject({ status: 'expired' }); + expect(await myInvites(invitee)).toEqual([]); + expect((await invitee.client.post(`/chat/invites/${sent.id}/accept`)).status).toBe(404); + }); +}); + +describe('chat room invites: finding players', () => { + 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)}`; + 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.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({ + username: available.username, + avatarUrl: null, + }); + }); + + 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'); + 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: 'available' }, + { 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 available', 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: 'available' }, + ]); + }); + + 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'); + 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); + }); +});