diff --git a/docs/catalog.json b/docs/catalog.json index d8343dc2..37d19df5 100644 --- a/docs/catalog.json +++ b/docs/catalog.json @@ -30,20 +30,34 @@ "tables": [ "chat_message", "chat_room", + "chat_room_ban", + "chat_room_member", "chat_user_block" ], "routes": [ + "chat.banMember", "chat.blockUser", + "chat.createPrivateRoom", + "chat.createRoom", "chat.deleteMessage", + "chat.deleteRoom", "chat.getConnection", "chat.getGlobalMessages", + "chat.getOnlineCount", + "chat.getRoom", "chat.getRoomMessages", + "chat.joinRoom", + "chat.kickMember", + "chat.leaveRoom", + "chat.listAdminRooms", "chat.listBlockedUsers", + "chat.listRoomMembers", "chat.listRooms", "chat.sendGlobalMessage", "chat.sendRoomMessage", "chat.streamMessages", - "chat.unblockUser" + "chat.unblockUser", + "chat.updateRoom" ] }, { @@ -539,6 +553,14 @@ ], "events": [ "chat.message.sent", + "chat.private_room.created", + "chat.room.created", + "chat.room.deleted", + "chat.room.member.banned", + "chat.room.member.joined", + "chat.room.member.kicked", + "chat.room.member.left", + "chat.room.updated", "chat.user.blocked", "chat.user.unblocked", "cms.banner.created", @@ -631,6 +653,10 @@ "name": "AdminRoleWithGrantsSchema", "file": "packages/core/src/iam/contract/index.ts" }, + { + "name": "AdminRoomSortBySchema", + "file": "packages/core/src/engagement/chat/contract/index.ts" + }, { "name": "AdminTransactionDetailSchema", "file": "packages/core/src/admin-console/contract/index.ts" @@ -719,10 +745,30 @@ "name": "ChatMessageSchema", "file": "packages/core/src/engagement/chat/contract/index.ts" }, + { + "name": "ChatOnlineCountSchema", + "file": "packages/core/src/engagement/chat/contract/index.ts" + }, + { + "name": "ChatRoomCategorySchema", + "file": "packages/core/src/engagement/chat/contract/index.ts" + }, + { + "name": "ChatRoomMemberSchema", + "file": "packages/core/src/engagement/chat/contract/index.ts" + }, + { + "name": "ChatRoomRoleSchema", + "file": "packages/core/src/engagement/chat/contract/index.ts" + }, { "name": "ChatRoomSchema", "file": "packages/core/src/engagement/chat/contract/index.ts" }, + { + "name": "ChatRoomSlugSchema", + "file": "packages/core/src/engagement/chat/contract/index.ts" + }, { "name": "CountryCodeSchema", "file": "packages/core/src/contracts/schemas/igaming-config.ts" @@ -1111,6 +1157,10 @@ "name": "SetPlayerLimitInputSchema", "file": "packages/core/src/compliance/contract/rg.ts" }, + { + "name": "SortOrderSchema", + "file": "packages/core/src/engagement/chat/contract/index.ts" + }, { "name": "StartRoundInputSchema", "file": "packages/core/src/casino/gaming/contract/index.ts" @@ -1274,6 +1324,7 @@ "slots" ], "httpRoutes": [ + "DELETE /backoffice/chat/rooms/{id}", "DELETE /chat/blocks/{blockedId}", "DELETE /chat/messages/{id}", "DELETE /cms/banners/{id}", @@ -1286,6 +1337,7 @@ "DELETE /wallet/auto-withdrawal-rules/{userId}", "GET /audit/export", "GET /audit/logs", + "GET /backoffice/chat/rooms", "GET /backoffice/stats", "GET /backoffice/transactions", "GET /backoffice/transactions/{id}", @@ -1294,7 +1346,10 @@ "GET /chat/blocks", "GET /chat/connection", "GET /chat/global", + "GET /chat/online-count", "GET /chat/rooms", + "GET /chat/rooms/{roomId}", + "GET /chat/rooms/{roomId}/members", "GET /chat/rooms/{roomId}/messages", "GET /chat/stream", "GET /cms/banners", @@ -1335,12 +1390,19 @@ "GET /wallet/transactions", "GET /wallet/transactions/{userId}", "GET /wallet/withdrawals", + "PATCH /backoffice/chat/rooms/{id}", "PATCH /backoffice/users/{userId}", "PATCH /iam/roles/{roleId}", "PATCH /identity/profile", "PATCH /profile", + "POST /backoffice/chat/rooms", "POST /chat/blocks", "POST /chat/global", + "POST /chat/rooms/join", + "POST /chat/rooms/private", + "POST /chat/rooms/{roomId}/ban", + "POST /chat/rooms/{roomId}/kick", + "POST /chat/rooms/{roomId}/leave", "POST /chat/rooms/{roomId}/messages", "POST /cms/banners", "POST /cms/pages", diff --git a/packages/core/src/audit/plugin.ts b/packages/core/src/audit/plugin.ts index c74d217e..3fefaf48 100644 --- a/packages/core/src/audit/plugin.ts +++ b/packages/core/src/audit/plugin.ts @@ -235,6 +235,62 @@ function mapEventToRecord(topic: string, p: Record): RecordInpu }; } + // actorId = the moderator who acted; resource = the affected player in that room. + if (topic === 'chat.room.member.kicked' || topic === 'chat.room.member.banned') { + const actorKey = topic === 'chat.room.member.kicked' ? 'kickedBy' : 'bannedBy'; + return { + ...base, + actorType: 'player', + actorId: str(p[actorKey]), + resourceType: 'chat_room_member', + resourceId: str(p['userId']), + after: { roomId: str(p['roomId']) }, + }; + } + + // actorId = the player who created the room; resource = the new room. + if (topic === 'chat.private_room.created') { + return { + ...base, + actorType: 'player', + actorId: str(p['creatorId']), + resourceType: 'chat_room', + resourceId: str(p['roomId']), + }; + } + + if ( + topic === 'chat.room.created' || + topic === 'chat.room.updated' || + topic === 'chat.room.deleted' + ) { + return { + ...base, + actorType: 'admin', + actorId: str(p['actorId']), + resourceType: 'chat_room', + resourceId: str(p['roomId']), + ...(topic === 'chat.room.created' + ? { after: { name: str(p['name']), slug: str(p['slug']), category: str(p['category']) } } + : {}), + ...(topic === 'chat.room.updated' || topic === 'chat.room.deleted' + ? { before: isRecord(p['before']) ? p['before'] : null } + : {}), + ...(topic === 'chat.room.updated' ? { after: isRecord(p['after']) ? p['after'] : null } : {}), + }; + } + + if (topic === 'chat.room.member.joined' || topic === 'chat.room.member.left') { + return { + ...base, + actorType: 'player', + actorId: str(p['userId']), + resourceType: 'chat_room_member', + resourceId: str(p['userId']), + after: { roomId: str(p['roomId']) }, + }; + } + // actorType = admin (the only path flipping isActive is the back-office route); // resource = the subject user. after carries the new active state. if (topic === 'identity.user.deactivated' || topic === 'identity.user.reactivated') { @@ -397,6 +453,14 @@ const SUBSCRIBED_TOPICS: DomainEventName[] = [ // already persisted in chatMessage; the moderation/block actions are what we audit. 'chat.user.blocked', 'chat.user.unblocked', + 'chat.private_room.created', + 'chat.room.created', + 'chat.room.updated', + 'chat.room.deleted', + 'chat.room.member.joined', + 'chat.room.member.left', + 'chat.room.member.kicked', + 'chat.room.member.banned', 'compliance.limit.upserted', 'compliance.limit.removed', 'rg.limit.set', diff --git a/packages/core/src/contracts/adapters/realtime.ts b/packages/core/src/contracts/adapters/realtime.ts index 26c4fb37..d61a193a 100644 --- a/packages/core/src/contracts/adapters/realtime.ts +++ b/packages/core/src/contracts/adapters/realtime.ts @@ -14,9 +14,11 @@ import { createToken, type Token } from './token.js'; // connected-member count; managed vendors provide richer presence. Kept optional // (a capability flag, per ADR-0007) so the base port is the common denominator. export type RealtimePresence = { - join(channel: string, memberId: string): void; - leave(channel: string, memberId: string): void; - count(channel: string): number; + // `connectionId` distinguishes concurrent tabs for the same member. Counts remain + // per member, so one tab leaving never marks a user offline while another is active. + join(channel: string, memberId: string, connectionId: string): void; + leave(channel: string, memberId: string, connectionId: string): void; + count(channel: string): number | Promise; }; export type RealtimeTransport = { diff --git a/packages/core/src/contracts/schemas/events.ts b/packages/core/src/contracts/schemas/events.ts index 3df686d1..60a59c35 100644 --- a/packages/core/src/contracts/schemas/events.ts +++ b/packages/core/src/contracts/schemas/events.ts @@ -127,6 +127,41 @@ export const domainEventSchemas = { 'chat.user.blocked': z.object({ blockerId: UuidSchema, blockedId: UuidSchema }), 'chat.user.unblocked': z.object({ blockerId: UuidSchema, blockedId: UuidSchema }), + // Admin room CRUD (actorId = acting admin UUID). + 'chat.room.created': z.object({ + roomId: UuidSchema, + name: z.string(), + slug: z.string(), + category: z.string(), + actorId: UuidSchema.optional(), + }), + 'chat.room.deleted': z.object({ + roomId: UuidSchema, + actorId: UuidSchema.optional(), + before: z.object({ name: z.string(), slug: z.string(), category: z.string() }), + }), + 'chat.room.updated': z.object({ + roomId: UuidSchema, + actorId: UuidSchema.optional(), + before: z.object({ name: z.string(), slug: z.string(), category: z.string() }), + after: z.object({ name: z.string(), slug: z.string(), category: z.string() }), + }), + + // Private room lifecycle: creation and member membership changes. + 'chat.private_room.created': z.object({ roomId: UuidSchema, creatorId: UuidSchema }), + 'chat.room.member.joined': z.object({ roomId: UuidSchema, userId: UuidSchema }), + 'chat.room.member.left': z.object({ roomId: UuidSchema, userId: UuidSchema }), + 'chat.room.member.kicked': z.object({ + roomId: UuidSchema, + userId: UuidSchema, + kickedBy: UuidSchema, + }), + 'chat.room.member.banned': z.object({ + roomId: UuidSchema, + userId: UuidSchema, + bannedBy: UuidSchema, + }), + // An admin added or changed a geo (country) rule (regulatory). `actorId` is the // acting admin so the audit log can attribute the mutation. 'compliance.geo-rule.added': z.object({ diff --git a/packages/core/src/engagement/chat/AGENTS.md b/packages/core/src/engagement/chat/AGENTS.md index 15583b3c..b1a7e8c1 100644 --- a/packages/core/src/engagement/chat/AGENTS.md +++ b/packages/core/src/engagement/chat/AGENTS.md @@ -1,7 +1,23 @@ # Chat -Room-based and global messaging. Tables: `chatRoom` (name, slug unique, visibility flag), `chatMessage` (soft-delete via `isDeleted`, indexed by room + createdAt), `chatUserBlock` (directional mute, blocker-keyed). +Room-based and global messaging. Tables: `chatRoom` (name, slug unique, required category: games-sports/regions/languages/private-channels, isPublic flag, joinCode unique nullable for private rooms, creatorId nullable, soft-delete via `deletedAt`), `chatMessage` (soft-delete via `isDeleted`, indexed by room + createdAt), `chatUserBlock` (directional mute, blocker-keyed), `chatRoomMember` (role: member/moderator, unique per room+user), `chatRoomBan` (unique per room+user). -Routes: `listRooms`, `getRoomMessages`/`sendRoomMessage` (both public for rooms; authenticated senders), `getGlobalMessages`/`sendGlobalMessage` (player-only, global scope), `deleteMessage` (player, ownership-enforced), `getConnection` (issues single-use realtime grant), `streamMessages` (SSE). +Routes (player-facing): `listRooms` (public rooms + private rooms the caller is a member of), `getRoomMessages`/`sendRoomMessage` (public read, authenticated send; private rooms require membership), `getGlobalMessages`/`sendGlobalMessage` (player-only, global scope), `deleteMessage` (player, ownership-enforced), `getConnection` (issues per-player realtime grant covering global + all public rooms + all private rooms where caller is a member), `streamMessages` (SSE, roomId null = global; private room check enforced at router level). -Per-viewer block filtering: message list filters out senders the viewer has blocked; blocked player is unaffected (can still send). Filters apply to both room and global streams. Username resolved from header (falls back to 'anonymous'); userId from auth. Realtime push over `REALTIME_TRANSPORT` + per-room grant (server authoritative, never client-supplied). +Presence and FE adoption: call `getOnlineCount({ roomId })` to display the current unique online count (`roomId: null` is global chat). Opening the SSE `streamMessages` route enters presence and closing it leaves; authenticated tabs are de-duplicated per user, while anonymous global viewers count separately. A managed realtime adapter (Ably, etc.) must enter and leave presence on the same `chat:global` / `chat:room:{id}` channel for each connection, implement `RealtimePresence.count()` against that provider, and refresh/revoke client grants after a room membership change. + +Routes (private room lifecycle): `createPrivateRoom` (player creates up to 15 active private-channels rooms, auto-joined as moderator, join code generated), `joinRoom` (join by code; 404 if invalid, 403 if banned), `leaveRoom` (idempotent), `getRoom` (room detail; joinCode populated for members of private rooms), `kickMember` (moderator removes member - can rejoin), `banMember` (moderator bans member - cannot rejoin even with code; idempotent), `listRoomMembers` (members only for private rooms). + +Routes (admin-only, AdminGuard-enforced): `createRoom` (POST /backoffice/chat/rooms - creates public room by slug and required category), `listAdminRooms` (GET /backoffice/chat/rooms - paginated public rooms, sortable by name or creation time), `updateRoom` (PATCH /backoffice/chat/rooms/{id} - changes name, slug, and/or category), `deleteRoom` (DELETE /backoffice/chat/rooms/{id} - soft-deletes the room while preserving messages, memberships, and bans). + +Per-viewer block filtering: message list filters out senders the viewer has blocked; blocked player is unaffected (can still send). Filters apply to both room and global streams. Username resolved from the verified user row (falls back to header, then 'anonymous'); userId from auth. Realtime push over `REALTIME_TRANSPORT`. + +Access model: public rooms are open to all; private rooms require membership. Deleted rooms are excluded from all room reads, lists, joins, and moderation operations. `verifyRoomAccess(roomId, viewerId?)` is the single authority - called by `getRoomMessages`, `sendRoomMessage`, `getRoom`, `listRoomMembers`, `leaveRoom`, `kickMember`, `banMember`, and (via router) `streamMessages`. Moderator check in `kickMember`/`banMember`: caller must have `role = 'moderator'` in `chatRoomMember`. + +Audit events: `chat.private_room.created`, `chat.room.member.kicked`, `chat.room.member.banned` are subscribed by the audit module. `chat.user.blocked`/`chat.user.unblocked` also audited. `chat.message.sent` is NOT audited (high-volume). + +Join code: 6 chars, 31-char alphabet (no ambiguous 0/1/I/O/L), crypto `randomInt`. Slug for private rooms auto-generated as `private-{joinCode.toLowerCase()}`. Join code is globally unique (DB unique index). + +Channel name convention (mirrors `chatChannel()` in the service): `chat:global` for null roomId, `chat:room:{roomId}` for rooms. + +Admin role requires `chat-room` resource declared in `server/auth/permissions.ts`. diff --git a/packages/core/src/engagement/chat/__tests__/chat.service.test.ts b/packages/core/src/engagement/chat/__tests__/chat.service.test.ts index 61e1b245..1a213c1f 100644 --- a/packages/core/src/engagement/chat/__tests__/chat.service.test.ts +++ b/packages/core/src/engagement/chat/__tests__/chat.service.test.ts @@ -1,7 +1,11 @@ -import { describe, it, expect } from 'vitest'; -import { InProcessRealtimeTransport, type EventBus } from '@openora/core/server'; +import { describe, it, expect, vi } from 'vitest'; +import { + InProcessRealtimeTransport, + type EventBus, + type DrizzleService, +} from '@openora/core/server'; import type { ChatMessage } from '../contract/index.js'; -import { mock, mockDb } from '../../../testing/mock.js'; +import { mock, mockDb, makeDrizzle, makeEvents, readPrivate } from '../../../testing/mock.js'; import { ChatService, chatChannel, @@ -10,6 +14,13 @@ import { ChatMessageOwnershipError, ChatMessageBlockedError, ChatSelfBlockError, + ChatRoomSlugConflictError, + ChatRoomJoinCodeNotFoundError, + ChatRoomBannedError, + ChatRoomLimitReachedError, + ChatRoomNotMemberError, + ChatRoomNotModeratorError, + ChatRoomSelfModerationError, } from '../service/chat.service.js'; describe('ChatService domain errors', () => { @@ -27,11 +38,10 @@ describe('ChatService domain errors', () => { expect(err.message).toContain('msg-456'); }); - it('ChatMessageOwnershipError carries the id', () => { - const err = new ChatMessageOwnershipError('msg-789'); + it('ChatMessageOwnershipError is a typed error', () => { + const err = new ChatMessageOwnershipError(); expect(err).toBeInstanceOf(Error); expect(err.name).toBe('ChatMessageOwnershipError'); - expect(err.message).toContain('msg-789'); }); }); @@ -72,6 +82,24 @@ describe('ChatService realtime wiring', () => { transport.publish(chatChannel('r1'), sample); expect(got).toHaveLength(1); }); + + it('counts each authenticated user once across multiple stream connections', async () => { + const transport = new InProcessRealtimeTransport(); + const service = new ChatService( + mockDb({}), + mock({ emit: () => undefined }), + transport, + ); + + const first = service.subscribeMessages('r1', () => undefined, 'u1', 'tab-1'); + const second = service.subscribeMessages('r1', () => undefined, 'u1', 'tab-2'); + + await expect(service.getOnlineCount('r1')).resolves.toEqual({ count: 1 }); + first(); + await expect(service.getOnlineCount('r1')).resolves.toEqual({ count: 1 }); + second(); + await expect(service.getOnlineCount('r1')).resolves.toEqual({ count: 0 }); + }); }); describe('ChatService.subscribeMessages per-viewer block filtering (AC11)', () => { @@ -245,3 +273,299 @@ describe('ChatService.blockUser', () => { await expect(service.blockUser('u1', 'u1')).rejects.toThrow(ChatSelfBlockError); }); }); + +function makeAdminService(drizzle: DrizzleService) { + return new ChatService( + drizzle, + mock({ emit: () => undefined }), + new InProcessRealtimeTransport(), + ); +} + +// Public room row shape shared by admin room tests. +const publicRoomRow = { + id: 'r1', + name: 'Jackpot Wheel', + slug: 'jackpot-wheel', + category: 'games-sports', + isPublic: true, + joinCode: null, + creatorId: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), +}; + +describe('ChatService.createRoom', () => { + it('creates a room and returns the serialized row', async () => { + // (1) slug uniqueness check -> empty, (2) insert -> no-op via select queue. + const drizzle = makeDrizzle({ select: [[]], returning: [[publicRoomRow]] }); + const result = await makeAdminService(drizzle).createRoom({ + name: 'Jackpot Wheel', + slug: 'jackpot-wheel', + category: 'games-sports', + }); + expect(result.id).toBe('r1'); + expect(result.slug).toBe('jackpot-wheel'); + expect(result.createdAt).toBe('2026-01-01T00:00:00.000Z'); + }); + + it('throws ChatRoomSlugConflictError when slug already exists', async () => { + // slug uniqueness check -> existing row found. + const drizzle = makeDrizzle({ select: [[{ id: 'existing' }]] }); + await expect( + makeAdminService(drizzle).createRoom({ + name: 'Jackpot Wheel', + slug: 'jackpot-wheel', + category: 'games-sports', + }), + ).rejects.toThrow(ChatRoomSlugConflictError); + }); +}); + +describe('ChatService.listAdminRooms', () => { + it('returns a paginated public-room list for the requested sort', async () => { + const drizzle = makeDrizzle({ select: [[publicRoomRow], [{ n: 1 }]] }); + + await expect( + makeAdminService(drizzle).listAdminRooms({ + page: 2, + limit: 10, + sortBy: 'name', + sortOrder: 'asc', + }), + ).resolves.toEqual({ + items: [{ ...publicRoomRow, createdAt: '2026-01-01T00:00:00.000Z' }], + total: 1, + page: 2, + limit: 10, + }); + }); +}); + +describe('ChatService.updateRoom', () => { + it('updates a room and emits an audit-ready before/after event', async () => { + const updatedRoom = { + ...publicRoomRow, + name: 'Mega Wheel', + slug: 'mega-wheel', + category: 'regions', + }; + const drizzle = makeDrizzle({ select: [[publicRoomRow]], returning: [[updatedRoom]] }); + const events = makeEvents(); + const service = new ChatService( + drizzle, + mock(events), + new InProcessRealtimeTransport(), + ); + + await expect( + service.updateRoom({ + id: 'r1', + name: 'Mega Wheel', + slug: 'mega-wheel', + category: 'regions', + actorId: 'admin-1', + }), + ).resolves.toMatchObject({ name: 'Mega Wheel', slug: 'mega-wheel', category: 'regions' }); + + expect(events.emit).toHaveBeenCalledWith('chat.room.updated', { + roomId: 'r1', + actorId: 'admin-1', + before: { name: 'Jackpot Wheel', slug: 'jackpot-wheel', category: 'games-sports' }, + after: { name: 'Mega Wheel', slug: 'mega-wheel', category: 'regions' }, + }); + }); +}); + +describe('ChatService.deleteRoom', () => { + it('deletes room messages then the room and returns success', async () => { + // In tx: (1) delete chatMessage (awaited, pops select), (2) delete chatRoom (pops returning). + const drizzle = makeDrizzle({ select: [[]], returning: [[{ id: 'r1' }]] }); + const result = await makeAdminService(drizzle).deleteRoom('r1'); + expect(result).toEqual({ success: true }); + }); + + it('throws ChatRoomNotFoundError when room does not exist', async () => { + // (1) delete chatMessage ok, (2) delete chatRoom returns nothing -> not found. + const drizzle = makeDrizzle({ select: [[]], returning: [[]] }); + await expect(makeAdminService(drizzle).deleteRoom('nonexistent')).rejects.toThrow( + ChatRoomNotFoundError, + ); + }); +}); + +// Private room row shape shared by private room tests. +const privateRoomRow = { + id: 'r2', + name: 'Squad Chat', + slug: 'private-abc123', + category: 'private-channels', + isPublic: false, + joinCode: 'ABC123', + creatorId: 'u1', + createdAt: new Date('2026-01-01T00:00:00.000Z'), +}; + +describe('ChatService.createPrivateRoom', () => { + it('creates a private room, joins creator as moderator, and returns serialized row', async () => { + // (1) count private rooms -> 0, in tx: (2) insert chatRoom -> returning[0], (3) insert member -> no-op + const drizzle = makeDrizzle({ select: [[{ total: 0 }], []], returning: [[privateRoomRow]] }); + const result = await makeAdminService(drizzle).createPrivateRoom({ + userId: 'u1', + name: 'Squad Chat', + }); + expect(result.id).toBe('r2'); + expect(result.isPublic).toBe(false); + expect(result.joinCode).toBe('ABC123'); + expect(result.creatorId).toBe('u1'); + expect(result.category).toBe('private-channels'); + }); + + it('rejects creating a sixteenth active private room', async () => { + const drizzle = makeDrizzle({ select: [[{ total: 15 }]] }); + + await expect( + makeAdminService(drizzle).createPrivateRoom({ userId: 'u1', name: 'One Too Many' }), + ).rejects.toThrow(ChatRoomLimitReachedError); + }); + + it('takes a creator-scoped advisory lock before counting and inserting', async () => { + const drizzle = makeDrizzle({ select: [[{ total: 0 }]], returning: [[privateRoomRow]] }); + const executeSpy = readPrivate>(drizzle.db, 'execute'); + + await makeAdminService(drizzle).createPrivateRoom({ userId: 'u1', name: 'Squad Chat' }); + + expect(executeSpy).toHaveBeenCalledTimes(1); + }); +}); + +describe('ChatService.joinRoom', () => { + it('joins a private room via join code and returns the room', async () => { + // (1) find room by joinCode -> privateRoomRow, (2) check ban -> empty, (3) insert member -> no-op + const drizzle = makeDrizzle({ select: [[privateRoomRow], [], []] }); + const result = await makeAdminService(drizzle).joinRoom({ userId: 'u2', joinCode: 'ABC123' }); + expect(result.id).toBe('r2'); + expect(result.joinCode).toBe('ABC123'); + }); + + it('throws ChatRoomJoinCodeNotFoundError when code does not match any room', async () => { + // (1) find room by joinCode -> empty + const drizzle = makeDrizzle({ select: [[]] }); + await expect( + makeAdminService(drizzle).joinRoom({ userId: 'u2', joinCode: 'BADCODE' }), + ).rejects.toThrow(ChatRoomJoinCodeNotFoundError); + }); + + it('throws ChatRoomBannedError when user is banned from the room', async () => { + // (1) find room -> privateRoomRow, (2) check ban -> ban record found + const drizzle = makeDrizzle({ select: [[privateRoomRow], [{ id: 'ban1' }]] }); + await expect( + makeAdminService(drizzle).joinRoom({ userId: 'u2', joinCode: 'ABC123' }), + ).rejects.toThrow(ChatRoomBannedError); + }); +}); + +describe('ChatService.kickMember', () => { + it('kicks a member from a private room and returns success', async () => { + // Access check (room + membership), then moderator lookup and member deletion. + const drizzle = makeDrizzle({ + select: [[privateRoomRow], [{ id: 'mem1' }], [{ role: 'moderator' }]], + }); + const result = await makeAdminService(drizzle).kickMember({ + moderatorId: 'u1', + roomId: 'r2', + userId: 'u2', + }); + expect(result).toEqual({ success: true }); + }); + + it('throws ChatRoomNotModeratorError when caller is not a moderator', async () => { + const drizzle = makeDrizzle({ + select: [[privateRoomRow], [{ id: 'mem1' }], [{ role: 'member' }]], + }); + await expect( + makeAdminService(drizzle).kickMember({ moderatorId: 'u1', roomId: 'r2', userId: 'u2' }), + ).rejects.toThrow(ChatRoomNotModeratorError); + }); + + it('throws ChatRoomNotModeratorError when caller is not in the room', async () => { + const drizzle = makeDrizzle({ select: [[privateRoomRow], [{ id: 'mem1' }], []] }); + await expect( + makeAdminService(drizzle).kickMember({ moderatorId: 'u1', roomId: 'r2', userId: 'u2' }), + ).rejects.toThrow(ChatRoomNotModeratorError); + }); + + it('throws ChatRoomSelfModerationError when trying to kick yourself', async () => { + const drizzle = makeDrizzle({ select: [] }); + await expect( + makeAdminService(drizzle).kickMember({ moderatorId: 'u1', roomId: 'r2', userId: 'u1' }), + ).rejects.toThrow(ChatRoomSelfModerationError); + }); +}); + +describe('ChatService.banMember', () => { + it('bans a member from a private room (also removes membership) and returns success', async () => { + // Access check (room + membership), then moderator lookup, ban insertion, and member deletion. + const drizzle = makeDrizzle({ + select: [[privateRoomRow], [{ id: 'mem1' }], [{ role: 'moderator' }]], + }); + const result = await makeAdminService(drizzle).banMember({ + moderatorId: 'u1', + roomId: 'r2', + userId: 'u2', + }); + expect(result).toEqual({ success: true }); + }); + + it('throws ChatRoomNotModeratorError when caller is not a moderator', async () => { + const drizzle = makeDrizzle({ select: [[privateRoomRow], [{ id: 'mem1' }], []] }); + await expect( + makeAdminService(drizzle).banMember({ moderatorId: 'u1', roomId: 'r2', userId: 'u2' }), + ).rejects.toThrow(ChatRoomNotModeratorError); + }); + + it('throws ChatRoomSelfModerationError when trying to ban yourself', async () => { + const drizzle = makeDrizzle({ select: [] }); + await expect( + makeAdminService(drizzle).banMember({ moderatorId: 'u1', roomId: 'r2', userId: 'u1' }), + ).rejects.toThrow(ChatRoomSelfModerationError); + }); +}); + +describe('ChatService.verifyRoomAccess', () => { + it('returns the room for public rooms without checking membership', async () => { + // (1) room lookup -> public room + const drizzle = makeDrizzle({ select: [[publicRoomRow]] }); + const room = await makeAdminService(drizzle).verifyRoomAccess('r1'); + expect(room.id).toBe('r1'); + }); + + it('throws ChatRoomNotFoundError when room does not exist', async () => { + const drizzle = makeDrizzle({ select: [[]] }); + await expect(makeAdminService(drizzle).verifyRoomAccess('r1')).rejects.toThrow( + ChatRoomNotFoundError, + ); + }); + + it('throws ChatRoomNotMemberError for private rooms when no viewerId supplied', async () => { + // (1) room lookup -> private room + const drizzle = makeDrizzle({ select: [[privateRoomRow]] }); + await expect(makeAdminService(drizzle).verifyRoomAccess('r2')).rejects.toThrow( + ChatRoomNotMemberError, + ); + }); + + it('throws ChatRoomNotMemberError for private rooms when viewer is not a member', async () => { + // (1) room lookup -> private room, (2) member check -> empty + const drizzle = makeDrizzle({ select: [[privateRoomRow], []] }); + await expect(makeAdminService(drizzle).verifyRoomAccess('r2', 'outsider')).rejects.toThrow( + ChatRoomNotMemberError, + ); + }); + + it('allows access for private rooms when viewer is a member', async () => { + // (1) room lookup -> private room, (2) member check -> member record + const drizzle = makeDrizzle({ select: [[privateRoomRow], [{ id: 'mem1' }]] }); + const room = await makeAdminService(drizzle).verifyRoomAccess('r2', 'u1'); + expect(room.id).toBe('r2'); + }); +}); diff --git a/packages/core/src/engagement/chat/contract/constants.ts b/packages/core/src/engagement/chat/contract/constants.ts new file mode 100644 index 00000000..70a5341f --- /dev/null +++ b/packages/core/src/engagement/chat/contract/constants.ts @@ -0,0 +1,15 @@ +export const MAX_MESSAGE_LENGTH = 500; +export const DEFAULT_MESSAGE_LIMIT = 50; +export const ROOM_NAME_MAX_LENGTH = 100; +export const ROOM_SLUG_MAX_LENGTH = 100; + +// Join code: drawn from a 31-char alphabet that omits visually ambiguous glyphs (0 1 I L O). +export const JOIN_CODE_LENGTH = 6; +export const JOIN_CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; +export const JOIN_CODE_INPUT_MAX_LENGTH = 20; + +export const PRIVATE_ROOM_SLUG_PREFIX = 'private-'; + +export const MAX_PRIVATE_ROOMS_PER_PLAYER = 15; + +export const CHAT_ROOM_ROLES = ['member', 'moderator'] as const; diff --git a/packages/core/src/engagement/chat/contract/index.ts b/packages/core/src/engagement/chat/contract/index.ts index 43209b02..daa989ef 100644 --- a/packages/core/src/engagement/chat/contract/index.ts +++ b/packages/core/src/engagement/chat/contract/index.ts @@ -1,28 +1,74 @@ import { oc, eventIterator } from '@orpc/contract'; import * as z from 'zod'; import { IdInputSchema, TimestampSchema, UuidSchema } from '@openora/core/contracts'; - -export const MAX_MESSAGE_LENGTH = 500; +import { PageQuerySchema, paginated } from '@openora/core/contracts/kit'; +import { + MAX_MESSAGE_LENGTH, + ROOM_NAME_MAX_LENGTH, + ROOM_SLUG_MAX_LENGTH, + JOIN_CODE_INPUT_MAX_LENGTH, + CHAT_ROOM_ROLES, +} from './constants.js'; + +export * from './constants.js'; + +export const AdminRoomSortByValues = ['name', 'createdAt'] as const; +export const AdminRoomSortBySchema = z.enum(AdminRoomSortByValues).default('createdAt'); +export type AdminRoomSortBy = z.infer; + +export const SortOrderValues = ['asc', 'desc'] as const; +export const SortOrderSchema = z.enum(SortOrderValues).default('desc'); +export type SortOrder = z.infer; + +// kebab-case slug: lowercase alphanum + hyphens, no leading/trailing hyphen. +export const ChatRoomSlugSchema = z + .string() + .trim() + .min(1) + .max(ROOM_SLUG_MAX_LENGTH) + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/); export const MessageContentSchema = z.string().trim().min(1).max(MAX_MESSAGE_LENGTH); +export const ChatRoomRoleSchema = z.enum(CHAT_ROOM_ROLES); +export type ChatRoomRole = z.infer; + +export const CHAT_ROOM_CATEGORIES = [ + 'games-sports', + 'regions', + 'languages', + 'private-channels', +] as const; +export const ChatRoomCategorySchema = z.enum(CHAT_ROOM_CATEGORIES); +export type ChatRoomCategory = z.infer; + export const ChatRoomSchema = z.object({ id: UuidSchema, name: z.string(), slug: z.string(), + category: ChatRoomCategorySchema, isPublic: z.boolean(), + // Null for public rooms; populated for private rooms when the viewer is a member. + joinCode: z.string().nullable(), + creatorId: UuidSchema.nullable(), createdAt: TimestampSchema, }); export type ChatRoom = z.infer; +export const ChatRoomMemberSchema = z.object({ + userId: UuidSchema, + role: ChatRoomRoleSchema, + joinedAt: TimestampSchema, +}); +export type ChatRoomMember = z.infer; + export const ChatMessageSchema = z.object({ id: UuidSchema, roomId: UuidSchema.nullable(), userId: UuidSchema, username: z.string(), - // UNTRUSTED user text. Profanity-gated and dangerous-URL-defanged server-side - // (best-effort), but NOT HTML-escaped. Consumers MUST render it as text or - // HTML-escape it - never inject it as raw HTML. See moderation/sanitize-urls.ts. + // UNTRUSTED user text: profanity-gated and URL-defanged server-side but NOT HTML-escaped. + // Consumers MUST render as text or escape before injecting into HTML. content: z.string(), isDeleted: z.boolean(), createdAt: TimestampSchema, @@ -34,6 +80,8 @@ export const BlockedUserSchema = z.object({ createdAt: TimestampSchema, }); +export const ChatOnlineCountSchema = z.object({ count: z.number().int().min(0) }); + // `.loose()` keeps this an open union so a managed-vendor overlay (eg Ably) can return extra fields without a contract change. export const ChatConnectionGrantSchema = z .object({ @@ -42,7 +90,10 @@ export const ChatConnectionGrantSchema = z }) .loose(); -// Default first-party SSE; swappable for a managed vendor (Ably/GetStream) downstream. See ADR-0007. +const RoomIdInput = z.object({ roomId: UuidSchema }); +const RoomUserInput = z.object({ roomId: UuidSchema, userId: UuidSchema }); +const ChatJoinCodeSchema = z.string().trim().min(1).max(JOIN_CODE_INPUT_MAX_LENGTH); + export const chatContract = { listRooms: oc.route({ method: 'GET', path: '/chat/rooms' }).output(z.array(ChatRoomSchema)), @@ -51,7 +102,7 @@ export const chatContract = { .input( z.object({ roomId: UuidSchema, - // Bounded so a caller cannot request an unbounded page (matches the 50 default). + // Bounded so a caller cannot request an unbounded page. limit: z.number().int().min(1).max(100).optional(), before: z.string().optional(), }), @@ -87,6 +138,11 @@ export const chatContract = { .input(z.object({ roomId: UuidSchema.nullable().optional() })) .output(eventIterator(ChatMessageSchema)), + getOnlineCount: oc + .route({ method: 'GET', path: '/chat/online-count' }) + .input(z.object({ roomId: UuidSchema.nullable().optional() })) + .output(ChatOnlineCountSchema), + listBlockedUsers: oc .route({ method: 'GET', path: '/chat/blocks' }) .output(z.array(BlockedUserSchema)), @@ -100,4 +156,86 @@ export const chatContract = { .route({ method: 'DELETE', path: '/chat/blocks/{blockedId}' }) .input(z.object({ blockedId: UuidSchema })) .output(z.object({ success: z.literal(true) })), + + createPrivateRoom: oc + .route({ method: 'POST', path: '/chat/rooms/private' }) + .input(z.object({ name: z.string().trim().min(1).max(ROOM_NAME_MAX_LENGTH) })) + .output(ChatRoomSchema), + + joinRoom: oc + .route({ method: 'POST', path: '/chat/rooms/join' }) + .input(z.object({ joinCode: ChatJoinCodeSchema })) + .output(ChatRoomSchema), + + leaveRoom: oc + .route({ method: 'POST', path: '/chat/rooms/{roomId}/leave' }) + .input(RoomIdInput) + .output(z.object({ success: z.literal(true) })), + + getRoom: oc + .route({ method: 'GET', path: '/chat/rooms/{roomId}' }) + .input(RoomIdInput) + .output(ChatRoomSchema), + + kickMember: oc + .route({ method: 'POST', path: '/chat/rooms/{roomId}/kick' }) + .input(RoomUserInput) + .output(z.object({ success: z.literal(true) })), + + banMember: oc + .route({ method: 'POST', path: '/chat/rooms/{roomId}/ban' }) + .input(RoomUserInput) + .output(z.object({ success: z.literal(true) })), + + listRoomMembers: oc + .route({ method: 'GET', path: '/chat/rooms/{roomId}/members' }) + .input(RoomIdInput) + .output(z.array(ChatRoomMemberSchema)), + + createRoom: oc + .route({ method: 'POST', path: '/backoffice/chat/rooms' }) + .input( + z.object({ + name: z.string().trim().min(1).max(ROOM_NAME_MAX_LENGTH), + slug: ChatRoomSlugSchema, + category: ChatRoomCategorySchema, + }), + ) + .output(ChatRoomSchema), + + updateRoom: oc + .route({ method: 'PATCH', path: '/backoffice/chat/rooms/{id}' }) + .input( + z + .object({ + id: UuidSchema, + name: z.string().trim().min(1).max(ROOM_NAME_MAX_LENGTH).optional(), + slug: ChatRoomSlugSchema.optional(), + category: ChatRoomCategorySchema.optional(), + }) + .refine( + ({ name, slug, category }) => + name !== undefined || slug !== undefined || category !== undefined, + { + message: 'At least one room field is required', + }, + ), + ) + .output(ChatRoomSchema), + + listAdminRooms: oc + .route({ method: 'GET', path: '/backoffice/chat/rooms' }) + .input( + z.object({ + ...PageQuerySchema.shape, + sortBy: AdminRoomSortBySchema, + sortOrder: SortOrderSchema, + }), + ) + .output(paginated(ChatRoomSchema)), + + deleteRoom: oc + .route({ method: 'DELETE', path: '/backoffice/chat/rooms/{id}' }) + .input(IdInputSchema) + .output(z.object({ success: z.literal(true) })), }; diff --git a/packages/core/src/engagement/chat/drizzle/migrations/0001_nifty_infant_terrible.sql b/packages/core/src/engagement/chat/drizzle/migrations/0001_nifty_infant_terrible.sql new file mode 100644 index 00000000..94ee5fbc --- /dev/null +++ b/packages/core/src/engagement/chat/drizzle/migrations/0001_nifty_infant_terrible.sql @@ -0,0 +1,28 @@ +DO $$ BEGIN CREATE TYPE "public"."chat_room_role" AS ENUM('member', 'moderator'); EXCEPTION WHEN duplicate_object THEN null; END $$; +--> statement-breakpoint +CREATE TABLE "chat_room_ban" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "room_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "banned_by" uuid NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "chat_room_member" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "room_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "role" "chat_room_role" DEFAULT 'member' NOT NULL, + "joined_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "chat_room" ADD COLUMN "join_code" text;--> statement-breakpoint +ALTER TABLE "chat_room" ADD COLUMN "creator_id" uuid;--> statement-breakpoint +ALTER TABLE "chat_room_ban" ADD CONSTRAINT "chat_room_ban_room_id_chat_room_id_fk" FOREIGN KEY ("room_id") REFERENCES "public"."chat_room"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "chat_room_member" ADD CONSTRAINT "chat_room_member_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_ban_room_user_key" ON "chat_room_ban" USING btree ("room_id","user_id");--> statement-breakpoint +CREATE INDEX "chat_room_ban_room_idx" ON "chat_room_ban" USING btree ("room_id");--> statement-breakpoint +CREATE UNIQUE INDEX "chat_room_member_room_user_key" ON "chat_room_member" USING btree ("room_id","user_id");--> statement-breakpoint +CREATE INDEX "chat_room_member_room_idx" ON "chat_room_member" USING btree ("room_id");--> statement-breakpoint +CREATE INDEX "chat_room_member_user_idx" ON "chat_room_member" USING btree ("user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "chat_room_join_code_key" ON "chat_room" USING btree ("join_code"); diff --git a/packages/core/src/engagement/chat/drizzle/migrations/0002_curly_phalanx.sql b/packages/core/src/engagement/chat/drizzle/migrations/0002_curly_phalanx.sql new file mode 100644 index 00000000..8f585f81 --- /dev/null +++ b/packages/core/src/engagement/chat/drizzle/migrations/0002_curly_phalanx.sql @@ -0,0 +1,3 @@ +ALTER TABLE "chat_room" ADD COLUMN "deleted_at" timestamp with time zone;--> statement-breakpoint +CREATE INDEX "chat_room_deleted_at_idx" ON "chat_room" USING btree ("deleted_at");--> statement-breakpoint +CREATE INDEX "chat_room_creator_public_deleted_at_idx" ON "chat_room" USING btree ("creator_id","is_public","deleted_at"); diff --git a/packages/core/src/engagement/chat/drizzle/migrations/0003_youthful_peter_parker.sql b/packages/core/src/engagement/chat/drizzle/migrations/0003_youthful_peter_parker.sql new file mode 100644 index 00000000..c716ed40 --- /dev/null +++ b/packages/core/src/engagement/chat/drizzle/migrations/0003_youthful_peter_parker.sql @@ -0,0 +1,2 @@ +CREATE TYPE "public"."chat_room_category" AS ENUM('games-sports', 'regions', 'languages', 'private-channels');--> statement-breakpoint +ALTER TABLE "chat_room" ADD COLUMN "category" "chat_room_category" DEFAULT 'games-sports' NOT NULL; diff --git a/packages/core/src/engagement/chat/drizzle/migrations/meta/0001_snapshot.json b/packages/core/src/engagement/chat/drizzle/migrations/meta/0001_snapshot.json new file mode 100644 index 00000000..aef4595c --- /dev/null +++ b/packages/core/src/engagement/chat/drizzle/migrations/meta/0001_snapshot.json @@ -0,0 +1,490 @@ +{ + "id": "debf1404-02d1-42ba-88aa-30c5b33c5798", + "prevId": "2cca50fd-580d-4669-b4c1-dcddc6019daf", + "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 + }, + "is_deleted": { + "name": "is_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": {} + } + }, + "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": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_room": { + "name": "chat_room", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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()" + } + }, + "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": {} + } + }, + "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()" + } + }, + "indexes": { + "chat_room_ban_room_user_key": { + "name": "chat_room_ban_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_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": {} + } + }, + "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_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'" + }, + "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_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()" + } + }, + "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, + "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 + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/core/src/engagement/chat/drizzle/migrations/meta/0002_snapshot.json b/packages/core/src/engagement/chat/drizzle/migrations/meta/0002_snapshot.json new file mode 100644 index 00000000..5533c2a8 --- /dev/null +++ b/packages/core/src/engagement/chat/drizzle/migrations/meta/0002_snapshot.json @@ -0,0 +1,538 @@ +{ + "id": "5b4fac89-47ec-4b24-80f9-9521a6887a96", + "prevId": "debf1404-02d1-42ba-88aa-30c5b33c5798", + "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 + }, + "is_deleted": { + "name": "is_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": {} + } + }, + "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": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_room": { + "name": "chat_room", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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 + } + }, + "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": {} + } + }, + "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()" + } + }, + "indexes": { + "chat_room_ban_room_user_key": { + "name": "chat_room_ban_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_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": {} + } + }, + "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_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'" + }, + "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_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()" + } + }, + "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, + "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 + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/core/src/engagement/chat/drizzle/migrations/meta/0003_snapshot.json b/packages/core/src/engagement/chat/drizzle/migrations/meta/0003_snapshot.json new file mode 100644 index 00000000..810cfb04 --- /dev/null +++ b/packages/core/src/engagement/chat/drizzle/migrations/meta/0003_snapshot.json @@ -0,0 +1,552 @@ +{ + "id": "1600995c-c24a-457c-a188-7dace759753c", + "prevId": "5b4fac89-47ec-4b24-80f9-9521a6887a96", + "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 + }, + "is_deleted": { + "name": "is_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": {} + } + }, + "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": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_room": { + "name": "chat_room", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "chat_room_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'games-sports'" + }, + "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 + } + }, + "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": {} + } + }, + "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()" + } + }, + "indexes": { + "chat_room_ban_room_user_key": { + "name": "chat_room_ban_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_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": {} + } + }, + "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_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'" + }, + "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_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()" + } + }, + "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, + "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 + } + }, + "enums": { + "public.chat_room_category": { + "name": "chat_room_category", + "schema": "public", + "values": ["games-sports", "regions", "languages", "private-channels"] + } + }, + "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 2db14e5b..364b7041 100644 --- a/packages/core/src/engagement/chat/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/engagement/chat/drizzle/migrations/meta/_journal.json @@ -8,6 +8,27 @@ "when": 1783555224311, "tag": "0000_numerous_albert_cleary", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1784117825980, + "tag": "0001_nifty_infant_terrible", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1784294749394, + "tag": "0002_curly_phalanx", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1784308310854, + "tag": "0003_youthful_peter_parker", + "breakpoints": true } ] } diff --git a/packages/core/src/engagement/chat/index.ts b/packages/core/src/engagement/chat/index.ts index a70a3791..7fa0a850 100644 --- a/packages/core/src/engagement/chat/index.ts +++ b/packages/core/src/engagement/chat/index.ts @@ -1,2 +1 @@ -export { ChatService } from './service/chat.service.js'; -export { createChatRouter } from './router/index.js'; +export * from './contract/constants.js'; diff --git a/packages/core/src/engagement/chat/plugin.ts b/packages/core/src/engagement/chat/plugin.ts index 015c85dd..4c38e691 100644 --- a/packages/core/src/engagement/chat/plugin.ts +++ b/packages/core/src/engagement/chat/plugin.ts @@ -1,4 +1,4 @@ -import { definePlugin, EVENT_BUS, DRIZZLE } from '@openora/core/server'; +import { definePlugin, EVENT_BUS, DRIZZLE, ADMIN_GUARD } from '@openora/core/server'; import { REALTIME_TRANSPORT, REALTIME_CLIENT_AUTHORIZER } from '@openora/core/contracts'; import { ChatService } from './service/chat.service.js'; import { createChatRouter } from './router/index.js'; @@ -10,6 +10,7 @@ export default definePlugin({ createChatRouter( new ChatService(c.get(DRIZZLE), c.get(EVENT_BUS), c.get(REALTIME_TRANSPORT)), c.get(REALTIME_CLIENT_AUTHORIZER), + c.get(ADMIN_GUARD), ), ); }, diff --git a/packages/core/src/engagement/chat/react/use-join-room.ts b/packages/core/src/engagement/chat/react/use-join-room.ts new file mode 100644 index 00000000..733b1481 --- /dev/null +++ b/packages/core/src/engagement/chat/react/use-join-room.ts @@ -0,0 +1,33 @@ +'use client'; + +import { useCallback } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { useOptionalRealtimeClient } from '@openora/core/react'; +import type { ChatRoom } from '../contract/index.js'; + +/** + * Joins a private room by join code, then refreshes both the room list cache and the + * realtime connection token so the new channel is included in the next Ably/vendor grant. + * + * Usage: + * const join = useJoinRoom((input) => client.chat.joinRoom(input)); + * await join('ABC123'); + */ +export function useJoinRoom( + joinRoomFn: (input: { joinCode: string }) => Promise, +): (joinCode: string) => Promise { + const queryClient = useQueryClient(); + const adapter = useOptionalRealtimeClient(); + + return useCallback( + async (joinCode: string): Promise => { + const room = await joinRoomFn({ joinCode }); + // Refresh the vendor token first so the new channel is authorised before the + // room list re-renders and the consumer subscribes to it (BF-75 / ADR-0007). + adapter?.refresh?.(); + void queryClient.invalidateQueries({ queryKey: ['chat', 'rooms'] }); + return room; + }, + [joinRoomFn, queryClient, adapter], + ); +} diff --git a/packages/core/src/engagement/chat/router/index.ts b/packages/core/src/engagement/chat/router/index.ts index cf525115..b6fcc60a 100644 --- a/packages/core/src/engagement/chat/router/index.ts +++ b/packages/core/src/engagement/chat/router/index.ts @@ -4,6 +4,7 @@ import { mapErrors, createEventStreamGenerator, getUserId, + AdminGuard, type OssContext, } from '@openora/core/server'; import type { RealtimeClientAuthorizer } from '@openora/core/contracts'; @@ -12,10 +13,18 @@ import { ChatService, chatChannel, ChatRoomNotFoundError, + ChatRoomNotMemberError, + ChatRoomNotModeratorError, + ChatRoomSelfModerationError, + ChatRoomLastModeratorError, + ChatRoomLimitReachedError, ChatMessageNotFoundError, ChatMessageOwnershipError, ChatMessageBlockedError, ChatSelfBlockError, + ChatRoomSlugConflictError, + ChatRoomJoinCodeNotFoundError, + ChatRoomBannedError, } from '../service/chat.service.js'; const chat = populateContractRouterPaths({ chat: chatContract }).chat; @@ -34,28 +43,42 @@ function resolveViewerId(context: OssContext) { return context.auth?.userId; } -export function createChatRouter(chatService: ChatService, authorizer: RealtimeClientAuthorizer) { +export function createChatRouter( + chatService: ChatService, + authorizer: RealtimeClientAuthorizer, + adminGuard: AdminGuard, +) { const os = implement(chat).$context(); return os.router({ - listRooms: os.listRooms.handler(() => chatService.listRooms()), + listRooms: os.listRooms.handler(({ context }) => { + const userId = getUserId(context); + return chatService.listRooms(userId); + }), - getRoomMessages: os.getRoomMessages.handler(({ input, context }) => - mapErrors({ NOT_FOUND: ChatRoomNotFoundError }, () => - chatService.getRoomMessages({ - roomId: input.roomId, - limit: input.limit, - before: input.before, - viewerId: resolveViewerId(context), - }), - ), - ), + getRoomMessages: os.getRoomMessages.handler(({ input, context }) => { + const viewerId = getUserId(context); + return mapErrors( + { NOT_FOUND: ChatRoomNotFoundError, FORBIDDEN: ChatRoomNotMemberError }, + () => + chatService.getRoomMessages({ + roomId: input.roomId, + limit: input.limit, + before: input.before, + viewerId, + }), + ); + }), sendRoomMessage: os.sendRoomMessage.handler(({ input, context }) => { const userId = getUserId(context); const username = resolveUsername(context); return mapErrors( - { NOT_FOUND: ChatRoomNotFoundError, BAD_REQUEST: ChatMessageBlockedError }, + { + NOT_FOUND: ChatRoomNotFoundError, + FORBIDDEN: ChatRoomNotMemberError, + BAD_REQUEST: ChatMessageBlockedError, + }, () => chatService.sendRoomMessage({ userId, @@ -86,25 +109,43 @@ export function createChatRouter(chatService: ChatService, authorizer: RealtimeC ); }), - getConnection: os.getConnection.handler(({ input, context }) => { + // Grant includes all rooms the player has access to (public + private memberships) + // so Ably clients can subscribe to any accessible room without re-auth. + getConnection: os.getConnection.handler(async ({ input, context }) => { const userId = getUserId(context); - // Grant is a per-player single-use nonce - must never be cached. context.resHeaders?.set('cache-control', 'no-store'); - // Server is authoritative on channel scope; never trusts a client-supplied list. - const channels = [chatChannel(null)]; - return Promise.resolve( - authorizer.issueGrant({ userId, clientId: input.clientId ?? userId, channels }), - ); + const rooms = await chatService.listRooms(userId); + const channels = [chatChannel(null), ...rooms.map((r) => chatChannel(r.id))]; + return authorizer.issueGrant({ userId, clientId: input.clientId ?? userId, channels }); }), - streamMessages: os.streamMessages.handler(({ input, signal, context }) => { - const viewerId = resolveViewerId(context); + streamMessages: os.streamMessages.handler(async ({ input, signal, context }) => { + const roomId = input.roomId ?? null; + // Room streams require auth; global stream allows anonymous viewers. + const viewerId = roomId ? getUserId(context) : resolveViewerId(context); + if (roomId) { + await mapErrors( + { NOT_FOUND: ChatRoomNotFoundError, FORBIDDEN: ChatRoomNotMemberError }, + () => chatService.verifyRoomAccess(roomId, viewerId), + ); + } return createEventStreamGenerator( - (push) => chatService.subscribeMessages(input.roomId ?? null, push, viewerId), + (push) => chatService.subscribeMessages(roomId, push, viewerId), { signal }, ); }), + getOnlineCount: os.getOnlineCount.handler(async ({ input, context }) => { + const roomId = input.roomId ?? null; + if (roomId) { + await mapErrors( + { NOT_FOUND: ChatRoomNotFoundError, FORBIDDEN: ChatRoomNotMemberError }, + () => chatService.verifyRoomAccess(roomId, getUserId(context)), + ); + } + return chatService.getOnlineCount(roomId); + }), + listBlockedUsers: os.listBlockedUsers.handler(({ context }) => chatService.listBlockedUsers(getUserId(context)), ), @@ -119,5 +160,94 @@ export function createChatRouter(chatService: ChatService, authorizer: RealtimeC unblockUser: os.unblockUser.handler(({ input, context }) => chatService.unblockUser(getUserId(context), input.blockedId), ), + + createPrivateRoom: os.createPrivateRoom.handler(({ input, context }) => { + const userId = getUserId(context); + return mapErrors({ CONFLICT: ChatRoomLimitReachedError }, () => + chatService.createPrivateRoom({ userId, name: input.name }), + ); + }), + + joinRoom: os.joinRoom.handler(({ input, context }) => { + const userId = getUserId(context); + // TODO: apply RATE_LIMITER (5 req/15 min per IP+userId) to prevent join-code brute-force + return mapErrors( + { NOT_FOUND: ChatRoomJoinCodeNotFoundError, FORBIDDEN: ChatRoomBannedError }, + () => chatService.joinRoom({ userId, joinCode: input.joinCode }), + ); + }), + + leaveRoom: os.leaveRoom.handler(({ input, context }) => { + const userId = getUserId(context); + return mapErrors({ BAD_REQUEST: ChatRoomLastModeratorError }, () => + chatService.leaveRoom({ userId, roomId: input.roomId }), + ); + }), + + getRoom: os.getRoom.handler(({ input, context }) => + mapErrors({ NOT_FOUND: ChatRoomNotFoundError, FORBIDDEN: ChatRoomNotMemberError }, () => + chatService.getRoom({ roomId: input.roomId, viewerId: getUserId(context) }), + ), + ), + + kickMember: os.kickMember.handler(({ input, context }) => { + const moderatorId = getUserId(context); + return mapErrors( + { + NOT_FOUND: ChatRoomNotFoundError, + FORBIDDEN: ChatRoomNotModeratorError, + BAD_REQUEST: ChatRoomSelfModerationError, + }, + () => chatService.kickMember({ moderatorId, roomId: input.roomId, userId: input.userId }), + ); + }), + + banMember: os.banMember.handler(({ input, context }) => { + const moderatorId = getUserId(context); + return mapErrors( + { + NOT_FOUND: ChatRoomNotFoundError, + FORBIDDEN: ChatRoomNotModeratorError, + BAD_REQUEST: ChatRoomSelfModerationError, + }, + () => chatService.banMember({ moderatorId, roomId: input.roomId, userId: input.userId }), + ); + }), + + listRoomMembers: os.listRoomMembers.handler(({ input, context }) => + mapErrors({ NOT_FOUND: ChatRoomNotFoundError, FORBIDDEN: ChatRoomNotMemberError }, () => + chatService.listRoomMembers({ + roomId: input.roomId, + viewerId: getUserId(context), + }), + ), + ), + + createRoom: os.createRoom.handler(async ({ input, context }) => { + const { userId } = await adminGuard.assert(context, 'chat-room', 'create'); + return mapErrors({ CONFLICT: ChatRoomSlugConflictError }, () => + chatService.createRoom({ ...input, actorId: userId }), + ); + }), + + updateRoom: os.updateRoom.handler(async ({ input, context }) => { + const { userId } = await adminGuard.assert(context, 'chat-room', 'update'); + return mapErrors( + { NOT_FOUND: ChatRoomNotFoundError, CONFLICT: ChatRoomSlugConflictError }, + () => chatService.updateRoom({ ...input, actorId: userId }), + ); + }), + + listAdminRooms: os.listAdminRooms.handler(async ({ input, context }) => { + await adminGuard.assert(context, 'chat-room', 'view'); + return chatService.listAdminRooms(input); + }), + + deleteRoom: os.deleteRoom.handler(async ({ input, context }) => { + const { userId } = await adminGuard.assert(context, 'chat-room', 'delete'); + return mapErrors({ NOT_FOUND: ChatRoomNotFoundError }, () => + chatService.deleteRoom(input.id, userId), + ); + }), }); } diff --git a/packages/core/src/engagement/chat/schema/index.ts b/packages/core/src/engagement/chat/schema/index.ts index 22a7e9d4..07651864 100644 --- a/packages/core/src/engagement/chat/schema/index.ts +++ b/packages/core/src/engagement/chat/schema/index.ts @@ -1,4 +1,17 @@ -import { pgTable, uuid, text, boolean, timestamp, uniqueIndex, index } from 'drizzle-orm/pg-core'; +import { + pgTable, + pgEnum, + uuid, + text, + boolean, + timestamp, + uniqueIndex, + index, +} from 'drizzle-orm/pg-core'; +import { CHAT_ROOM_CATEGORIES, CHAT_ROOM_ROLES } from '../contract/index.js'; + +const chatRoomRole = pgEnum('chat_room_role', CHAT_ROOM_ROLES); +export const chatRoomCategory = pgEnum('chat_room_category', CHAT_ROOM_CATEGORIES); export const chatRoom = pgTable( 'chat_room', @@ -6,10 +19,20 @@ export const chatRoom = pgTable( id: uuid().primaryKey().defaultRandom(), name: text().notNull(), slug: text().notNull(), + category: chatRoomCategory().notNull().default('games-sports'), isPublic: boolean().notNull().default(true), + // Set only for private rooms; null for public/admin-created rooms. + joinCode: text(), + creatorId: uuid(), // bare id - cross-module (user from pam/identity), no FK createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), + deletedAt: timestamp({ withTimezone: true }), }, - (t) => [uniqueIndex('chat_room_slug_key').on(t.slug)], + (t) => [ + uniqueIndex('chat_room_slug_key').on(t.slug), + uniqueIndex('chat_room_join_code_key').on(t.joinCode), + index('chat_room_deleted_at_idx').on(t.deletedAt), + index('chat_room_creator_public_deleted_at_idx').on(t.creatorId, t.isPublic, t.deletedAt), + ], ); export const chatMessage = pgTable( @@ -45,6 +68,43 @@ export const chatUserBlock = pgTable( ], ); +export const chatRoomMember = pgTable( + 'chat_room_member', + { + id: uuid().primaryKey().defaultRandom(), + roomId: uuid() + .notNull() + .references(() => chatRoom.id, { onDelete: 'cascade' }), + userId: uuid().notNull(), // bare id - cross-module (user from pam/identity), no FK + role: chatRoomRole().notNull().default('member'), + joinedAt: timestamp({ withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + uniqueIndex('chat_room_member_room_user_key').on(t.roomId, t.userId), + index('chat_room_member_room_idx').on(t.roomId), + index('chat_room_member_user_idx').on(t.userId), + ], +); + +export const chatRoomBan = pgTable( + 'chat_room_ban', + { + id: uuid().primaryKey().defaultRandom(), + roomId: uuid() + .notNull() + .references(() => chatRoom.id, { onDelete: 'cascade' }), + userId: uuid().notNull(), // banned user - bare id + bannedBy: uuid().notNull(), // moderator who banned - bare id + createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + uniqueIndex('chat_room_ban_room_user_key').on(t.roomId, t.userId), + index('chat_room_ban_room_idx').on(t.roomId), + ], +); + export type ChatRoom = typeof chatRoom.$inferSelect; export type ChatMessage = typeof chatMessage.$inferSelect; export type ChatUserBlock = typeof chatUserBlock.$inferSelect; +export type ChatRoomMember = typeof chatRoomMember.$inferSelect; +export type ChatRoomBan = typeof chatRoomBan.$inferSelect; diff --git a/packages/core/src/engagement/chat/service/chat.service.ts b/packages/core/src/engagement/chat/service/chat.service.ts index cab9cdf2..8b7fa760 100644 --- a/packages/core/src/engagement/chat/service/chat.service.ts +++ b/packages/core/src/engagement/chat/service/chat.service.ts @@ -1,37 +1,51 @@ +import { randomInt, randomUUID } from 'node:crypto'; import { type EventBus, + makeNotFoundError, + makeOwnershipError, + makeConflictError, createDomainError, assertOwnership, DrizzleService, findOneOrThrow, serializeRow, + pageToOffset, + withAdvisoryXactLock, } from '@openora/core/server'; import type { RealtimeTransport } from '@openora/core/contracts'; -import { eq, and, isNull, lt, desc, asc, notInArray } from 'drizzle-orm'; +import { eq, and, isNull, lt, desc, asc, notInArray, inArray, count, ne } from 'drizzle-orm'; import { user } from '@openora/core/pam/schema/identity'; import type { User } from '@openora/core/pam/schema/identity'; -import { chatRoom, chatMessage, chatUserBlock } from '../schema/index.js'; -import type { ChatRoom, ChatMessage } from '../contract/index.js'; +import { + chatRoom, + chatMessage, + chatUserBlock, + chatRoomMember, + chatRoomBan, +} from '../schema/index.js'; +import type { + AdminRoomSortBy, + ChatRoom, + ChatMessage, + ChatRoomCategory, + SortOrder, +} from '../contract/index.js'; +import { + DEFAULT_MESSAGE_LIMIT, + JOIN_CODE_ALPHABET, + JOIN_CODE_LENGTH, + MAX_PRIVATE_ROOMS_PER_PLAYER, + PRIVATE_ROOM_SLUG_PREFIX, +} from '../contract/constants.js'; import { moderateContent } from '../moderation/index.js'; export function chatChannel(roomId: ChatRoom['id'] | null) { return roomId ? `chat:room:${roomId}` : 'chat:global'; } -export const ChatRoomNotFoundError = createDomainError( - 'ChatRoomNotFoundError', - (id: string) => `Chat room not found: ${id}`, -); - -export const ChatMessageNotFoundError = createDomainError( - 'ChatMessageNotFoundError', - (id: string) => `Chat message not found: ${id}`, -); - -export const ChatMessageOwnershipError = createDomainError( - 'ChatMessageOwnershipError', - (id: string) => `Not authorized to delete message: ${id}`, -); +export const ChatRoomNotFoundError = makeNotFoundError('ChatRoom'); +export const ChatMessageNotFoundError = makeNotFoundError('ChatMessage'); +export const ChatMessageOwnershipError = makeOwnershipError('ChatMessage'); export const ChatMessageBlockedError = createDomainError( 'ChatMessageBlockedError', @@ -43,6 +57,46 @@ export const ChatSelfBlockError = createDomainError( () => 'You cannot block yourself', ); +export const ChatRoomSlugConflictError = makeConflictError( + 'ChatRoomSlugConflictError', + 'A room with this slug already exists', +); + +export const ChatRoomLimitReachedError = makeConflictError( + 'ChatRoomLimitReachedError', + 'Private room limit reached', +); + +export const ChatRoomLastModeratorError = makeConflictError( + 'ChatRoomLastModeratorError', + 'Cannot leave: you are the sole moderator of this room', +); + +export const ChatRoomJoinCodeNotFoundError = createDomainError( + 'ChatRoomJoinCodeNotFoundError', + (code: string) => `No room found with join code: ${code}`, +); + +export const ChatRoomBannedError = createDomainError( + 'ChatRoomBannedError', + (roomId: ChatRoom['id']) => `You are banned from room: ${roomId}`, +); + +export const ChatRoomNotMemberError = createDomainError( + 'ChatRoomNotMemberError', + (roomId: ChatRoom['id']) => `You are not a member of room: ${roomId}`, +); + +export const ChatRoomNotModeratorError = createDomainError( + 'ChatRoomNotModeratorError', + (roomId: ChatRoom['id']) => `You are not a moderator of room: ${roomId}`, +); + +export const ChatRoomSelfModerationError = createDomainError( + 'ChatRoomSelfModerationError', + () => 'You cannot kick or ban yourself', +); + function gateContent(content: string): string { const result = moderateContent(content); if (!result.ok) { @@ -52,13 +106,25 @@ function gateContent(content: string): string { } function toRoom(record: typeof chatRoom.$inferSelect) { - return serializeRow(record, { dateFields: ['createdAt'] }); + const { deletedAt: _deletedAt, ...room } = record; + return serializeRow(room, { dateFields: ['createdAt'] }); } function toMessage(record: typeof chatMessage.$inferSelect) { return serializeRow(record, { dateFields: ['createdAt'] }); } +function generateJoinCode(): string { + return Array.from({ length: JOIN_CODE_LENGTH }, () => { + const ch = JOIN_CODE_ALPHABET[randomInt(0, JOIN_CODE_ALPHABET.length)]; + return ch ?? 'A'; // unreachable: randomInt(0, N) is always < N + }).join(''); +} + +function isUniqueConstraintViolation(e: unknown): boolean { + return (e as { code?: unknown }).code === '23505'; +} + export class ChatService { constructor( private readonly drizzle: DrizzleService, @@ -70,7 +136,12 @@ export class ChatService { roomId: ChatRoom['id'] | null, listener: (message: ChatMessage) => void, viewerId?: User['id'], + connectionId: string = randomUUID(), ) { + const channel = chatChannel(roomId); + const presenceMemberId = viewerId ?? `anonymous:${connectionId}`; + const presence = this.transport.presence; + presence?.join(channel, presenceMemberId, connectionId); let blocked: ReadonlySet | null = viewerId ? null : new Set(); const pending: ChatMessage[] = []; const deliver = (message: ChatMessage) => { @@ -89,13 +160,22 @@ export class ChatService { pending.length = 0; }); } - return this.transport.subscribe(chatChannel(roomId), (message) => { + const unsubscribe = this.transport.subscribe(channel, (message) => { if (blocked === null) { pending.push(message); } else { deliver(message); } }); + return () => { + unsubscribe(); + presence?.leave(channel, presenceMemberId, connectionId); + }; + } + + async getOnlineCount(roomId: ChatRoom['id'] | null) { + const count = await this.transport.presence?.count(chatChannel(roomId)); + return { count: count ?? 0 }; } private async blockedIdsFor(viewerId: User['id']) { @@ -115,14 +195,105 @@ export class ChatService { return row?.name?.trim() || fallback || 'anonymous'; } - async listRooms() { - const rooms = await this.drizzle.db.select().from(chatRoom).orderBy(asc(chatRoom.createdAt)); - return rooms.map(toRoom); + /** Throws ChatRoomNotFoundError or ChatRoomNotMemberError; returns the room on success. */ + async verifyRoomAccess(roomId: ChatRoom['id'], viewerId?: User['id']) { + const [room] = await this.drizzle.db + .select() + .from(chatRoom) + .where(and(eq(chatRoom.id, roomId), isNull(chatRoom.deletedAt))) + .limit(1); + if (!room) { + throw new ChatRoomNotFoundError(roomId); + } + if (!room.isPublic) { + if (!viewerId) { + throw new ChatRoomNotMemberError(roomId); + } + const [member] = await this.drizzle.db + .select({ id: chatRoomMember.id }) + .from(chatRoomMember) + .where(and(eq(chatRoomMember.roomId, roomId), eq(chatRoomMember.userId, viewerId))) + .limit(1); + if (!member) { + throw new ChatRoomNotMemberError(roomId); + } + } + return room; + } + + async listRooms(viewerId?: User['id']) { + const publicRooms = await this.drizzle.db + .select() + .from(chatRoom) + .where(and(eq(chatRoom.isPublic, true), isNull(chatRoom.deletedAt))) + .orderBy(asc(chatRoom.createdAt)); + + if (!viewerId) { + return publicRooms.map(toRoom); + } + + const memberIds = await this.drizzle.db + .select({ roomId: chatRoomMember.roomId }) + .from(chatRoomMember) + .where(eq(chatRoomMember.userId, viewerId)); + + if (memberIds.length === 0) { + return publicRooms.map(toRoom); + } + + const privateRooms = await this.drizzle.db + .select() + .from(chatRoom) + .where( + and( + eq(chatRoom.isPublic, false), + isNull(chatRoom.deletedAt), + inArray( + chatRoom.id, + memberIds.map((r) => r.roomId), + ), + ), + ) + .orderBy(asc(chatRoom.createdAt)); + + return [...publicRooms, ...privateRooms].map(toRoom); + } + + async listAdminRooms({ + page, + limit, + sortBy, + sortOrder, + }: { + page: number; + limit: number; + sortBy: AdminRoomSortBy; + sortOrder: SortOrder; + }) { + const dir = sortOrder === 'asc' ? asc : desc; + const col = sortBy === 'name' ? chatRoom.name : chatRoom.createdAt; + const where = and(eq(chatRoom.isPublic, true), isNull(chatRoom.deletedAt)); + const [rows, [{ n }]] = await Promise.all([ + this.drizzle.db + .select() + .from(chatRoom) + .where(where) + .orderBy(dir(col)) + .limit(limit) + .offset(pageToOffset(page, limit)), + this.drizzle.db.select({ n: count() }).from(chatRoom).where(where), + ]); + return { items: rows.map(toRoom), total: Number(n), page, limit }; + } + + async getRoom({ roomId, viewerId }: { roomId: ChatRoom['id']; viewerId?: User['id'] }) { + const room = await this.verifyRoomAccess(roomId, viewerId); + return toRoom(room); } async getRoomMessages({ roomId, - limit = 50, + limit = DEFAULT_MESSAGE_LIMIT, before, viewerId, }: { @@ -131,6 +302,8 @@ export class ChatService { before?: string; viewerId?: User['id']; }) { + await this.verifyRoomAccess(roomId, viewerId); + const conditions = [eq(chatMessage.roomId, roomId), eq(chatMessage.isDeleted, false)]; if (before) { conditions.push(lt(chatMessage.createdAt, new Date(before))); @@ -166,10 +339,8 @@ export class ChatService { roomId: ChatRoom['id']; content: string; }) { - findOneOrThrow( - await this.drizzle.db.select().from(chatRoom).where(eq(chatRoom.id, roomId)), - new ChatRoomNotFoundError(roomId), - ); + // TODO: check RG_SELF_EXCLUSION_SERVICE before send (sealed token not yet implemented) + await this.verifyRoomAccess(roomId, userId); const safeContent = gateContent(content); const displayName = await this.resolveDisplayName(userId, username); @@ -199,7 +370,7 @@ export class ChatService { await this.drizzle.db.select().from(chatMessage).where(eq(chatMessage.id, id)), new ChatMessageNotFoundError(id), ); - assertOwnership(message.userId, userId, new ChatMessageOwnershipError(id)); + assertOwnership(message.userId, userId, new ChatMessageOwnershipError()); await this.drizzle.db .update(chatMessage) @@ -209,7 +380,7 @@ export class ChatService { return { success: true } as const; } - async getGlobalMessages(limit = 50, viewerId?: User['id']) { + async getGlobalMessages(limit = DEFAULT_MESSAGE_LIMIT, viewerId?: User['id']) { const conditions = [isNull(chatMessage.roomId), eq(chatMessage.isDeleted, false)]; await this.appendBlockFilter(conditions, viewerId); const messages = await this.drizzle.db @@ -222,6 +393,7 @@ export class ChatService { } async sendGlobalMessage(userId: User['id'], username: string, content: string) { + // TODO: check RG_SELF_EXCLUSION_SERVICE before send (sealed token not yet implemented) const safeContent = gateContent(content); const displayName = await this.resolveDisplayName(userId, username); const [record] = await this.drizzle.db @@ -283,4 +455,298 @@ export class ChatService { } return { success: true } as const; } + + async createRoom({ + name, + slug, + category, + actorId, + }: { + name: string; + slug: string; + category: ChatRoomCategory; + actorId?: User['id']; + }) { + const [existing] = await this.drizzle.db + .select({ id: chatRoom.id }) + .from(chatRoom) + .where(eq(chatRoom.slug, slug)) + .limit(1); + if (existing) { + throw new ChatRoomSlugConflictError(); + } + const [record] = await this.drizzle.db + .insert(chatRoom) + .values({ name, slug, category }) + .returning(); + this.events.emit('chat.room.created', { roomId: record.id, name, slug, category, actorId }); + return toRoom(record); + } + + async updateRoom({ + id, + name, + slug, + category, + actorId, + }: { + id: ChatRoom['id']; + name?: string; + slug?: string; + category?: ChatRoomCategory; + actorId?: User['id']; + }) { + const existing = findOneOrThrow( + await this.drizzle.db + .select() + .from(chatRoom) + .where(and(eq(chatRoom.id, id), isNull(chatRoom.deletedAt))) + .limit(1), + new ChatRoomNotFoundError(id), + ); + if (slug !== undefined) { + const [clash] = await this.drizzle.db + .select({ id: chatRoom.id }) + .from(chatRoom) + .where(and(eq(chatRoom.slug, slug), ne(chatRoom.id, id))) + .limit(1); + if (clash) { + throw new ChatRoomSlugConflictError(); + } + } + const patch: Partial = {}; + if (name !== undefined) { + patch.name = name; + } + if (slug !== undefined) { + patch.slug = slug; + } + if (category !== undefined) { + patch.category = category; + } + const updated = findOneOrThrow( + await this.drizzle.db + .update(chatRoom) + .set(patch) + .where(and(eq(chatRoom.id, id), isNull(chatRoom.deletedAt))) + .returning(), + new ChatRoomNotFoundError(id), + ); + this.events.emit('chat.room.updated', { + roomId: id, + actorId, + before: { name: existing.name, slug: existing.slug, category: existing.category }, + after: { name: updated.name, slug: updated.slug, category: updated.category }, + }); + return toRoom(updated); + } + + async deleteRoom(id: ChatRoom['id'], actorId?: User['id']) { + 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), + ); + this.events.emit('chat.room.deleted', { + roomId: id, + actorId, + before: { name: deleted.name, slug: deleted.slug, category: deleted.category }, + }); + return { success: true } as const; + } + + async createPrivateRoom({ userId, name }: { userId: User['id']; name: string }) { + for (let attempt = 1; attempt <= 3; attempt++) { + const joinCode = generateJoinCode(); + const slug = `${PRIVATE_ROOM_SLUG_PREFIX}${joinCode.toLowerCase()}`; + try { + const record = await this.drizzle.db.transaction((t) => + withAdvisoryXactLock(t, userId, async () => { + const [{ total }] = await t + .select({ total: count() }) + .from(chatRoom) + .where( + and( + eq(chatRoom.creatorId, userId), + eq(chatRoom.isPublic, false), + isNull(chatRoom.deletedAt), + ), + ); + if (Number(total) >= MAX_PRIVATE_ROOMS_PER_PLAYER) { + throw new ChatRoomLimitReachedError(); + } + + const [room] = await t + .insert(chatRoom) + .values({ + name, + slug, + category: 'private-channels', + isPublic: false, + joinCode, + creatorId: userId, + }) + .returning(); + await t + .insert(chatRoomMember) + .values({ roomId: room.id, userId, role: 'moderator' }) + .onConflictDoNothing(); + return room; + }), + ); + this.events.emit('chat.private_room.created', { roomId: record.id, creatorId: userId }); + return toRoom(record); + } catch (e) { + if (attempt < 3 && isUniqueConstraintViolation(e)) { + continue; + } + throw e; + } + } + // unreachable: loop returns or rethrows on every iteration + throw new ChatRoomSlugConflictError(); + } + + async joinRoom({ userId, joinCode }: { userId: User['id']; joinCode: string }) { + // TODO: apply RATE_LIMITER (5 req/15 min per IP+userId) to prevent join-code brute-force + const [room] = await this.drizzle.db + .select() + .from(chatRoom) + .where(and(eq(chatRoom.joinCode, joinCode), isNull(chatRoom.deletedAt))) + .limit(1); + if (!room) { + throw new ChatRoomJoinCodeNotFoundError(joinCode); + } + const [ban] = await this.drizzle.db + .select({ id: chatRoomBan.id }) + .from(chatRoomBan) + .where(and(eq(chatRoomBan.roomId, room.id), eq(chatRoomBan.userId, userId))) + .limit(1); + if (ban) { + throw new ChatRoomBannedError(room.id); + } + // Idempotent: re-joining is a no-op (preserves existing role). + const inserted = await this.drizzle.db + .insert(chatRoomMember) + .values({ roomId: room.id, userId }) + .onConflictDoNothing() + .returning(); + if (inserted.length > 0) { + this.events.emit('chat.room.member.joined', { roomId: room.id, userId }); + } + return toRoom(room); + } + + async leaveRoom({ userId, roomId }: { userId: User['id']; roomId: ChatRoom['id'] }) { + await this.verifyRoomAccess(roomId, userId); + const [memberRow] = await this.drizzle.db + .select({ role: chatRoomMember.role }) + .from(chatRoomMember) + .where(and(eq(chatRoomMember.roomId, roomId), eq(chatRoomMember.userId, userId))) + .limit(1); + if (memberRow?.role === 'moderator') { + const [{ modCount }] = await this.drizzle.db + .select({ modCount: count() }) + .from(chatRoomMember) + .where(and(eq(chatRoomMember.roomId, roomId), eq(chatRoomMember.role, 'moderator'))); + if (Number(modCount) <= 1) { + throw new ChatRoomLastModeratorError(); + } + } + const removed = await this.drizzle.db + .delete(chatRoomMember) + .where(and(eq(chatRoomMember.roomId, roomId), eq(chatRoomMember.userId, userId))) + .returning(); + if (removed.length > 0) { + this.events.emit('chat.room.member.left', { roomId, userId }); + } + return { success: true } as const; + } + + async kickMember({ + moderatorId, + roomId, + userId, + }: { + moderatorId: User['id']; + roomId: ChatRoom['id']; + userId: User['id']; + }) { + if (moderatorId === userId) { + throw new ChatRoomSelfModerationError(); + } + await this.verifyRoomAccess(roomId, moderatorId); + await this.drizzle.db.transaction(async (t) => { + const [mod] = await t + .select({ role: chatRoomMember.role }) + .from(chatRoomMember) + .where(and(eq(chatRoomMember.roomId, roomId), eq(chatRoomMember.userId, moderatorId))) + .limit(1); + if (!mod || mod.role !== 'moderator') { + throw new ChatRoomNotModeratorError(roomId); + } + await t + .delete(chatRoomMember) + .where(and(eq(chatRoomMember.roomId, roomId), eq(chatRoomMember.userId, userId))); + }); + this.events.emit('chat.room.member.kicked', { roomId, userId, kickedBy: moderatorId }); + return { success: true } as const; + } + + async banMember({ + moderatorId, + roomId, + userId, + }: { + moderatorId: User['id']; + roomId: ChatRoom['id']; + userId: User['id']; + }) { + if (moderatorId === userId) { + throw new ChatRoomSelfModerationError(); + } + await this.verifyRoomAccess(roomId, moderatorId); + // Idempotent: banning an already-banned user is a no-op. + await this.drizzle.db.transaction(async (t) => { + const [mod] = await t + .select({ role: chatRoomMember.role }) + .from(chatRoomMember) + .where(and(eq(chatRoomMember.roomId, roomId), eq(chatRoomMember.userId, moderatorId))) + .limit(1); + if (!mod || mod.role !== 'moderator') { + throw new ChatRoomNotModeratorError(roomId); + } + await t + .insert(chatRoomBan) + .values({ roomId, userId, bannedBy: moderatorId }) + .onConflictDoNothing(); + await t + .delete(chatRoomMember) + .where(and(eq(chatRoomMember.roomId, roomId), eq(chatRoomMember.userId, userId))); + }); + this.events.emit('chat.room.member.banned', { roomId, userId, bannedBy: moderatorId }); + return { success: true } as const; + } + + async listRoomMembers({ roomId, viewerId }: { roomId: ChatRoom['id']; viewerId?: User['id'] }) { + await this.verifyRoomAccess(roomId, viewerId); + const members = await this.drizzle.db + .select({ + userId: chatRoomMember.userId, + role: chatRoomMember.role, + joinedAt: chatRoomMember.joinedAt, + }) + .from(chatRoomMember) + .where(eq(chatRoomMember.roomId, roomId)) + .orderBy(asc(chatRoomMember.joinedAt)); + return members.map((m) => serializeRow(m, { dateFields: ['joinedAt'] })); + } } diff --git a/packages/core/src/engagement/react.ts b/packages/core/src/engagement/react.ts index e4557b27..35c2030a 100644 --- a/packages/core/src/engagement/react.ts +++ b/packages/core/src/engagement/react.ts @@ -5,3 +5,4 @@ export { type UseChatStreamOptions, type UseChatStreamResult, } from './chat/react/use-chat-stream.js'; +export { useJoinRoom } from './chat/react/use-join-room.js'; diff --git a/packages/core/src/react/context/realtime-client.tsx b/packages/core/src/react/context/realtime-client.tsx index e71a62f8..d570ec7c 100644 --- a/packages/core/src/react/context/realtime-client.tsx +++ b/packages/core/src/react/context/realtime-client.tsx @@ -20,6 +20,9 @@ export type RealtimeClientAdapter = { // Optional: vendor adapters (eg Ably) must implement this to avoid leaking a // WebSocket across Fast-Refresh / StrictMode remounts. close?(): void; + // Optional: called after channel membership changes (eg joining a private room) so + // vendor adapters can issue a new auth token covering the new channel before subscribe. + refresh?(): void; }; const RealtimeClientContext = createContext(null); diff --git a/packages/core/src/server/auth/permissions.ts b/packages/core/src/server/auth/permissions.ts index 5524e01a..5824526d 100644 --- a/packages/core/src/server/auth/permissions.ts +++ b/packages/core/src/server/auth/permissions.ts @@ -18,6 +18,7 @@ export const statement = { sessions: ['view', 'revoke'] as const, 'player-note': ['view', 'create'] as const, 'tag-rule': ['view', 'update'] as const, + 'chat-room': ['view', 'create', 'update', 'delete'] as const, } as const; export const ac = createAccessControl(statement); @@ -40,6 +41,7 @@ export const adminRole = ac.newRole({ sessions: ['view', 'revoke'], 'player-note': ['view', 'create'], 'tag-rule': ['view', 'update'], + 'chat-room': ['view', 'create', 'update', 'delete'], }); export const supportRole = ac.newRole({ diff --git a/packages/core/src/server/kernel/__tests__/realtime-transport.test.ts b/packages/core/src/server/kernel/__tests__/realtime-transport.test.ts index 11d20862..d8f9099b 100644 --- a/packages/core/src/server/kernel/__tests__/realtime-transport.test.ts +++ b/packages/core/src/server/kernel/__tests__/realtime-transport.test.ts @@ -44,11 +44,13 @@ describe('InProcessRealtimeTransport', () => { it('tracks presence counts per channel', () => { const t = new InProcessRealtimeTransport(); - t.presence.join('room', 'u1'); - t.presence.join('room', 'u2'); - t.presence.join('room', 'u2'); + t.presence.join('room', 'u1', 'tab-1'); + t.presence.join('room', 'u2', 'tab-1'); + t.presence.join('room', 'u2', 'tab-2'); expect(t.presence.count('room')).toBe(2); - t.presence.leave('room', 'u1'); + t.presence.leave('room', 'u2', 'tab-1'); + expect(t.presence.count('room')).toBe(2); + t.presence.leave('room', 'u1', 'tab-1'); expect(t.presence.count('room')).toBe(1); }); }); diff --git a/packages/core/src/server/kernel/realtime-transport.ts b/packages/core/src/server/kernel/realtime-transport.ts index 40b1e66a..2d094105 100644 --- a/packages/core/src/server/kernel/realtime-transport.ts +++ b/packages/core/src/server/kernel/realtime-transport.ts @@ -3,21 +3,27 @@ import type { RealtimeTransport, RealtimePresence } from '@openora/core/contract type Handler = (event: unknown) => void; class InProcessPresence implements RealtimePresence { - private readonly members = new Map>(); + private readonly members = new Map>>(); - join(channel: string, memberId: string): void { - const set = this.members.get(channel) ?? new Set(); - set.add(memberId); - this.members.set(channel, set); + join(channel: string, memberId: string, connectionId: string): void { + const channelMembers = this.members.get(channel) ?? new Map>(); + const connections = channelMembers.get(memberId) ?? new Set(); + connections.add(connectionId); + channelMembers.set(memberId, connections); + this.members.set(channel, channelMembers); } - leave(channel: string, memberId: string): void { - const set = this.members.get(channel); - if (!set) { + leave(channel: string, memberId: string, connectionId: string): void { + const channelMembers = this.members.get(channel); + const connections = channelMembers?.get(memberId); + if (!channelMembers || !connections) { return; } - set.delete(memberId); - if (set.size === 0) { + connections.delete(connectionId); + if (connections.size === 0) { + channelMembers.delete(memberId); + } + if (channelMembers.size === 0) { this.members.delete(channel); } } diff --git a/packages/testing/src/seed-demo-data.ts b/packages/testing/src/seed-demo-data.ts index 9c0ed259..5258219b 100644 --- a/packages/testing/src/seed-demo-data.ts +++ b/packages/testing/src/seed-demo-data.ts @@ -5,6 +5,7 @@ import { user } from '@openora/core/pam/schema/identity'; import { player } from '@openora/core/pam/schema/profile'; import { wallet, walletTransaction } from '@openora/core/wallet/schema'; import { game } from '@openora/core/casino/schema/gaming'; +import { chatRoom, chatMessage } from '@openora/core/engagement/schema/chat'; export type SeedAuth = { api: { @@ -32,6 +33,7 @@ export type SeedResult = { players: number; games: number; transactions: number; + rooms: number; }; function makeRng(seed: number): () => number { @@ -205,6 +207,42 @@ const GAMES = [ ['Plinko', 'Spribe', 'crash'], ] as const; +const CHAT_ROOMS = [ + { + slug: 'general', + name: 'General', + category: 'languages', + messages: [ + 'Welcome to the platform! Feel free to chat here.', + 'Have fun and good luck everyone!', + ], + }, + { + slug: 'sports-betting', + name: 'Sports Betting', + category: 'games-sports', + messages: [ + 'Big match tonight - who are you backing?', + 'Odds are looking great on the underdog.', + ], + }, + { + slug: 'big-wins', + name: 'Big Wins', + category: 'games-sports', + messages: ['Share your wins here!', 'Jackpot season has begun!'], + }, + { + slug: 'support', + name: 'Support', + category: 'regions', + messages: [ + 'Need help? Our team is here.', + 'For account issues please include your registered email.', + ], + }, +] as const; + export async function seedDemoData(options: SeedOptions): Promise { const { db, auth, playerCount = 36, windowDays = 90, log = () => {} } = options; const admin = options.admin ?? { @@ -215,7 +253,9 @@ export async function seedDemoData(options: SeedOptions): Promise { const playerPassword = options.password ?? 'password123'; const rng = makeRng(0x5eed); - log('Clearing existing demo content (player, wallet, transaction, game)...'); + log('Clearing existing demo content (player, wallet, transaction, game, chat)...'); + await db.delete(chatMessage); + await db.delete(chatRoom); await db.delete(walletTransaction); await db.delete(wallet); await db.delete(player); @@ -242,6 +282,38 @@ export async function seedDemoData(options: SeedOptions): Promise { ); log(`Created ${GAMES.length} games.`); + let roomCount = 0; + if (adminUser) { + const insertedRooms = await db + .insert(chatRoom) + .values( + CHAT_ROOMS.map((r) => ({ + name: r.name, + slug: r.slug, + category: r.category, + isPublic: true, + creatorId: adminUser.id, + })), + ) + .returning(); + + const messageRows: (typeof chatMessage.$inferInsert)[] = insertedRooms.flatMap((room) => { + const def = CHAT_ROOMS.find((r) => r.slug === room.slug); + return (def?.messages ?? []).map((content) => ({ + roomId: room.id, + userId: adminUser.id, + username: admin.name, + content, + })); + }); + if (messageRows.length > 0) { + await db.insert(chatMessage).values(messageRows); + } + + roomCount = insertedRooms.length; + log(`Created ${roomCount} public chat rooms with demo messages.`); + } + let userCount = adminUser ? 1 : 0; let txCount = 0; const now = Date.now(); @@ -357,6 +429,7 @@ export async function seedDemoData(options: SeedOptions): Promise { players: playerCount, games: GAMES.length, transactions: txCount, + rooms: roomCount, }; } diff --git a/tools/db/seed.ts b/tools/db/seed.ts index f32486ca..20117e1c 100644 --- a/tools/db/seed.ts +++ b/tools/db/seed.ts @@ -48,6 +48,7 @@ async function main() { db, auth, playerCount: Number(arg('players') ?? 36), + password: arg('password') ?? 'password123', admin: { email: arg('admin-email') ?? 'admin@oss.dev', password: arg('admin-password') ?? 'password123', @@ -61,6 +62,7 @@ async function main() { console.log(` Players: ${result.players}`); console.log(` Games: ${result.games}`); console.log(` Transactions: ${result.transactions}`); + console.log(` Chat rooms: ${result.rooms}`); console.log('\nLog in to the backoffice:'); console.log(` ${result.adminEmail} / ${result.adminPassword}`); }