@openora/core 0.4.1-canary.71 → 0.4.1-canary.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/admin-console/__tests__/backoffice.service.int.test.js +30 -12
  3. package/dist/admin-console/__tests__/backoffice.service.int.test.js.map +1 -1
  4. package/dist/admin-console/contract/index.d.ts +4 -0
  5. package/dist/admin-console/contract/index.d.ts.map +1 -1
  6. package/dist/admin-console/contract/index.js +3 -2
  7. package/dist/admin-console/contract/index.js.map +1 -1
  8. package/dist/admin-console/plugin.d.ts +1 -0
  9. package/dist/admin-console/plugin.d.ts.map +1 -1
  10. package/dist/admin-console/router/index.d.ts +2 -0
  11. package/dist/admin-console/router/index.d.ts.map +1 -1
  12. package/dist/admin-console/service/backoffice.service.d.ts +2 -0
  13. package/dist/admin-console/service/backoffice.service.d.ts.map +1 -1
  14. package/dist/admin-console/service/backoffice.service.js +2 -0
  15. package/dist/admin-console/service/backoffice.service.js.map +1 -1
  16. package/dist/analytics/plugin.d.ts +1 -0
  17. package/dist/analytics/plugin.d.ts.map +1 -1
  18. package/dist/audit/plugin.d.ts +1 -0
  19. package/dist/audit/plugin.d.ts.map +1 -1
  20. package/dist/audit/plugin.js +6 -0
  21. package/dist/audit/plugin.js.map +1 -1
  22. package/dist/casino/gaming/plugin.d.ts +1 -0
  23. package/dist/casino/gaming/plugin.d.ts.map +1 -1
  24. package/dist/casino/lobby/plugin.d.ts +1 -0
  25. package/dist/casino/lobby/plugin.d.ts.map +1 -1
  26. package/dist/cms/plugin.d.ts +1 -0
  27. package/dist/cms/plugin.d.ts.map +1 -1
  28. package/dist/compliance/plugin.d.ts +1 -0
  29. package/dist/compliance/plugin.d.ts.map +1 -1
  30. package/dist/contracts/adapters/admin-user-directory.d.ts +4 -2
  31. package/dist/contracts/adapters/admin-user-directory.d.ts.map +1 -1
  32. package/dist/contracts/adapters/admin-user-directory.js.map +1 -1
  33. package/dist/contracts/adapters/chat-moderation.d.ts +75 -0
  34. package/dist/contracts/adapters/chat-moderation.d.ts.map +1 -0
  35. package/dist/contracts/adapters/chat-moderation.js +3 -0
  36. package/dist/contracts/adapters/chat-moderation.js.map +1 -0
  37. package/dist/contracts/adapters/index.d.ts +2 -0
  38. package/dist/contracts/adapters/index.d.ts.map +1 -1
  39. package/dist/contracts/adapters/index.js +1 -0
  40. package/dist/contracts/adapters/index.js.map +1 -1
  41. package/dist/contracts/adapters/realtime.d.ts +9 -1
  42. package/dist/contracts/adapters/realtime.d.ts.map +1 -1
  43. package/dist/contracts/adapters/realtime.js.map +1 -1
  44. package/dist/contracts/schemas/events.d.ts +19 -4
  45. package/dist/contracts/schemas/events.d.ts.map +1 -1
  46. package/dist/contracts/schemas/events.js +12 -5
  47. package/dist/contracts/schemas/events.js.map +1 -1
  48. package/dist/engagement/chat/__tests__/chat.service.int.test.js +315 -19
  49. package/dist/engagement/chat/__tests__/chat.service.int.test.js.map +1 -1
  50. package/dist/engagement/chat/contract/constants.d.ts +1 -1
  51. package/dist/engagement/chat/contract/constants.d.ts.map +1 -1
  52. package/dist/engagement/chat/contract/constants.js +1 -1
  53. package/dist/engagement/chat/contract/constants.js.map +1 -1
  54. package/dist/engagement/chat/contract/index.d.ts +764 -18
  55. package/dist/engagement/chat/contract/index.d.ts.map +1 -1
  56. package/dist/engagement/chat/contract/index.js +236 -5
  57. package/dist/engagement/chat/contract/index.js.map +1 -1
  58. package/dist/engagement/chat/drizzle/migrations/0005_tranquil_gwen_stacy.sql +100 -0
  59. package/dist/engagement/chat/drizzle/migrations/0006_tiny_ultron.sql +3 -0
  60. package/dist/engagement/chat/drizzle/migrations/meta/0005_snapshot.json +1422 -0
  61. package/dist/engagement/chat/drizzle/migrations/meta/0006_snapshot.json +1456 -0
  62. package/dist/engagement/chat/drizzle/migrations/meta/_journal.json +14 -0
  63. package/dist/engagement/chat/plugin.d.ts +2 -1
  64. package/dist/engagement/chat/plugin.d.ts.map +1 -1
  65. package/dist/engagement/chat/plugin.js +25 -13
  66. package/dist/engagement/chat/plugin.js.map +1 -1
  67. package/dist/engagement/chat/router/index.d.ts +518 -18
  68. package/dist/engagement/chat/router/index.d.ts.map +1 -1
  69. package/dist/engagement/chat/router/index.js +140 -20
  70. package/dist/engagement/chat/router/index.js.map +1 -1
  71. package/dist/engagement/chat/schema/index.d.ts +1017 -16
  72. package/dist/engagement/chat/schema/index.d.ts.map +1 -1
  73. package/dist/engagement/chat/schema/index.js +110 -4
  74. package/dist/engagement/chat/schema/index.js.map +1 -1
  75. package/dist/engagement/chat/service/chat-ban.service.d.ts +26 -0
  76. package/dist/engagement/chat/service/chat-ban.service.d.ts.map +1 -0
  77. package/dist/engagement/chat/service/chat-ban.service.js +139 -0
  78. package/dist/engagement/chat/service/chat-ban.service.js.map +1 -0
  79. package/dist/engagement/chat/service/chat-message-moderation.service.d.ts +14 -0
  80. package/dist/engagement/chat/service/chat-message-moderation.service.d.ts.map +1 -0
  81. package/dist/engagement/chat/service/chat-message-moderation.service.js +48 -0
  82. package/dist/engagement/chat/service/chat-message-moderation.service.js.map +1 -0
  83. package/dist/engagement/chat/service/chat-moderation.service.d.ts +30 -0
  84. package/dist/engagement/chat/service/chat-moderation.service.d.ts.map +1 -0
  85. package/dist/engagement/chat/service/chat-moderation.service.js +40 -0
  86. package/dist/engagement/chat/service/chat-moderation.service.js.map +1 -0
  87. package/dist/engagement/chat/service/chat-mute.service.d.ts +26 -0
  88. package/dist/engagement/chat/service/chat-mute.service.d.ts.map +1 -0
  89. package/dist/engagement/chat/service/chat-mute.service.js +143 -0
  90. package/dist/engagement/chat/service/chat-mute.service.js.map +1 -0
  91. package/dist/engagement/chat/service/chat-room-ban.service.d.ts +29 -0
  92. package/dist/engagement/chat/service/chat-room-ban.service.d.ts.map +1 -0
  93. package/dist/engagement/chat/service/chat-room-ban.service.js +105 -0
  94. package/dist/engagement/chat/service/chat-room-ban.service.js.map +1 -0
  95. package/dist/engagement/chat/service/chat-room-membership.service.d.ts +72 -0
  96. package/dist/engagement/chat/service/chat-room-membership.service.d.ts.map +1 -0
  97. package/dist/engagement/chat/service/chat-room-membership.service.js +176 -0
  98. package/dist/engagement/chat/service/chat-room-membership.service.js.map +1 -0
  99. package/dist/engagement/chat/service/chat-room-mute.service.d.ts +25 -0
  100. package/dist/engagement/chat/service/chat-room-mute.service.d.ts.map +1 -0
  101. package/dist/engagement/chat/service/chat-room-mute.service.js +89 -0
  102. package/dist/engagement/chat/service/chat-room-mute.service.js.map +1 -0
  103. package/dist/engagement/chat/service/chat.service.d.ts +348 -61
  104. package/dist/engagement/chat/service/chat.service.d.ts.map +1 -1
  105. package/dist/engagement/chat/service/chat.service.js +460 -194
  106. package/dist/engagement/chat/service/chat.service.js.map +1 -1
  107. package/dist/engagement/chat/service/errors/chat-moderation.errors.d.ts +21 -0
  108. package/dist/engagement/chat/service/errors/chat-moderation.errors.d.ts.map +1 -0
  109. package/dist/engagement/chat/service/errors/chat-moderation.errors.js +31 -0
  110. package/dist/engagement/chat/service/errors/chat-moderation.errors.js.map +1 -0
  111. package/dist/engagement/chat-commands/contract/index.js +1 -1
  112. package/dist/engagement/chat-commands/contract/index.js.map +1 -1
  113. package/dist/engagement/chat-commands/drizzle/migrations/0001_sturdy_barracuda.sql +1 -0
  114. package/dist/engagement/chat-commands/drizzle/migrations/meta/0001_snapshot.json +83 -0
  115. package/dist/engagement/chat-commands/drizzle/migrations/meta/_journal.json +7 -0
  116. package/dist/engagement/chat-commands/plugin.d.ts +1 -0
  117. package/dist/engagement/chat-commands/plugin.d.ts.map +1 -1
  118. package/dist/engagement/chat-commands/schema/index.d.ts +0 -199
  119. package/dist/engagement/chat-commands/schema/index.d.ts.map +1 -1
  120. package/dist/engagement/chat-commands/schema/index.js +1 -19
  121. package/dist/engagement/chat-commands/schema/index.js.map +1 -1
  122. package/dist/engagement/chat-commands/service/__tests__/chat-commands.service.test.js +15 -1
  123. package/dist/engagement/chat-commands/service/__tests__/chat-commands.service.test.js.map +1 -1
  124. package/dist/engagement/chat-commands/service/chat-commands.service.d.ts +1 -0
  125. package/dist/engagement/chat-commands/service/chat-commands.service.d.ts.map +1 -1
  126. package/dist/engagement/chat-commands/service/chat-commands.service.js +38 -3
  127. package/dist/engagement/chat-commands/service/chat-commands.service.js.map +1 -1
  128. package/dist/engagement/notifications/plugin.d.ts +1 -0
  129. package/dist/engagement/notifications/plugin.d.ts.map +1 -1
  130. package/dist/engagement/social/plugin.d.ts +1 -0
  131. package/dist/engagement/social/plugin.d.ts.map +1 -1
  132. package/dist/engagement/social-transfers/plugin.d.ts +1 -0
  133. package/dist/engagement/social-transfers/plugin.d.ts.map +1 -1
  134. package/dist/engagement/social-transfers/schema/index.d.ts +1 -2
  135. package/dist/engagement/social-transfers/schema/index.d.ts.map +1 -1
  136. package/dist/engagement/social-transfers/schema/index.js +1 -2
  137. package/dist/engagement/social-transfers/schema/index.js.map +1 -1
  138. package/dist/engagement/social-transfers/service/__tests__/social-transfers.service.test.js +19 -0
  139. package/dist/engagement/social-transfers/service/__tests__/social-transfers.service.test.js.map +1 -1
  140. package/dist/iam/plugin.d.ts +1 -0
  141. package/dist/iam/plugin.d.ts.map +1 -1
  142. package/dist/iam/seed/data/default-admin-roles.d.ts.map +1 -1
  143. package/dist/iam/seed/data/default-admin-roles.js +3 -0
  144. package/dist/iam/seed/data/default-admin-roles.js.map +1 -1
  145. package/dist/pam/identity/__tests__/admin-user-directory.int.test.js +33 -3
  146. package/dist/pam/identity/__tests__/admin-user-directory.int.test.js.map +1 -1
  147. package/dist/pam/identity/admin-user-directory.d.ts +12 -0
  148. package/dist/pam/identity/admin-user-directory.d.ts.map +1 -1
  149. package/dist/pam/identity/admin-user-directory.js +25 -25
  150. package/dist/pam/identity/admin-user-directory.js.map +1 -1
  151. package/dist/pam/identity/plugin.d.ts +1 -0
  152. package/dist/pam/identity/plugin.d.ts.map +1 -1
  153. package/dist/pam/player-management/__tests__/player.service.int.test.js +4 -0
  154. package/dist/pam/player-management/__tests__/player.service.int.test.js.map +1 -1
  155. package/dist/pam/player-management/plugin.d.ts +1 -0
  156. package/dist/pam/player-management/plugin.d.ts.map +1 -1
  157. package/dist/pam/player-note/plugin.d.ts +1 -0
  158. package/dist/pam/player-note/plugin.d.ts.map +1 -1
  159. package/dist/pam/profile/plugin.d.ts +1 -0
  160. package/dist/pam/profile/plugin.d.ts.map +1 -1
  161. package/dist/pam/tag/plugin.d.ts +1 -0
  162. package/dist/pam/tag/plugin.d.ts.map +1 -1
  163. package/dist/server/auth/permissions.d.ts +12 -12
  164. package/dist/server/auth/permissions.js +2 -2
  165. package/dist/server/auth/permissions.js.map +1 -1
  166. package/dist/server/kernel/__tests__/realtime-transport.test.js +7 -0
  167. package/dist/server/kernel/__tests__/realtime-transport.test.js.map +1 -1
  168. package/dist/server/kernel/orpc-error-map.d.ts.map +1 -1
  169. package/dist/server/kernel/orpc-error-map.js +4 -1
  170. package/dist/server/kernel/orpc-error-map.js.map +1 -1
  171. package/dist/server/kernel/realtime-transport.d.ts +3 -1
  172. package/dist/server/kernel/realtime-transport.d.ts.map +1 -1
  173. package/dist/server/kernel/realtime-transport.js +22 -4
  174. package/dist/server/kernel/realtime-transport.js.map +1 -1
  175. package/dist/server/runtime/core-token-catalog.d.ts +1 -0
  176. package/dist/server/runtime/core-token-catalog.d.ts.map +1 -1
  177. package/dist/server/runtime/core-token-catalog.js +2 -1
  178. package/dist/server/runtime/core-token-catalog.js.map +1 -1
  179. package/dist/testing/realtime-transport-conformance.d.ts.map +1 -1
  180. package/dist/testing/realtime-transport-conformance.js +4 -0
  181. package/dist/testing/realtime-transport-conformance.js.map +1 -1
  182. package/dist/wallet/__tests__/wallet.service.int.test.js +19 -2
  183. package/dist/wallet/__tests__/wallet.service.int.test.js.map +1 -1
  184. package/dist/wallet/contract/index.d.ts +2 -0
  185. package/dist/wallet/contract/index.d.ts.map +1 -1
  186. package/dist/wallet/contract/index.js +1 -0
  187. package/dist/wallet/contract/index.js.map +1 -1
  188. package/dist/wallet/plugin.d.ts +1 -0
  189. package/dist/wallet/plugin.d.ts.map +1 -1
  190. package/dist/wallet/router/index.d.ts +1 -0
  191. package/dist/wallet/router/index.d.ts.map +1 -1
  192. package/dist/wallet/service/wallet.service.d.ts +1 -0
  193. package/dist/wallet/service/wallet.service.d.ts.map +1 -1
  194. package/dist/wallet/service/wallet.service.js +1 -0
  195. package/dist/wallet/service/wallet.service.js.map +1 -1
  196. package/package.json +1 -1
@@ -1,9 +1,13 @@
1
1
  import { randomInt, randomUUID } from 'node:crypto';
2
- import { createLogger, makeNotFoundError, makeOwnershipError, makeConflictError, createDomainError, assertOwnership, findOneOrThrow, serializeRow, pageToOffset, withAdvisoryXactLock, } from '@openora/core/server';
3
- import { chatChannel } from '@openora/core/contracts';
4
- import { eq, and, isNull, lt, desc, asc, notInArray, inArray, count, ne } from 'drizzle-orm';
2
+ import { createLogger, makeOwnershipError, makeConflictError, createDomainError, assertOwnership, findOneOrThrow, serializeRow, pageToOffset, withAdvisoryXactLock, } from '@openora/core/server';
3
+ import { chatChannel, GLOBAL_CHAT_ROOM_ID } from '@openora/core/contracts';
4
+ import { eq, and, isNull, or, gt, lt, desc, asc, notInArray, inArray, count, ne, ilike, } from 'drizzle-orm';
5
5
  import { user } from '@openora/core/pam/schema/identity';
6
- import { chatRoom, chatMessage, chatUserBlock, chatUserIgnore, chatRoomMember, chatRoomBan, } from '../schema/index.js';
6
+ import { player } from '@openora/core/pam/schema/profile';
7
+ import { chatRoom, chatMessage, chatUserBlock, chatUserIgnore, chatRoomMember, chatRoomBan, chatRoomRule, chatRoomConfiguration, chatPlatformBan, } from '../schema/index.js';
8
+ import { ChatMessageNotFoundError, ChatRoomNotFoundError } from './chat-moderation.service.js';
9
+ export { ChatRoomNotMemberError, ChatRoomNotModeratorError, ChatRoomSelfModerationError, ChatRoomLastModeratorError, ChatRoomJoinCodeNotFoundError, ChatRoomBannedError, ChatPlayerBannedError, } from './errors/chat-moderation.errors.js';
10
+ import { ChatRoomNotMemberError, ChatRoomNotModeratorError, ChatPlayerBannedError, } from './errors/chat-moderation.errors.js';
7
11
  import { DEFAULT_MESSAGE_LIMIT, JOIN_CODE_ALPHABET, JOIN_CODE_LENGTH, MAX_PRIVATE_ROOMS_PER_PLAYER, PRIVATE_ROOM_SLUG_PREFIX, } from '../contract/constants.js';
8
12
  import { moderateContent } from '../moderation/index.js';
9
13
  const logger = createLogger('chat');
@@ -14,21 +18,15 @@ function publishChatEvent(transport, roomId, event) {
14
18
  logger.error({ err, roomId }, 'chat realtime publish failed');
15
19
  });
16
20
  }
17
- export const ChatRoomNotFoundError = makeNotFoundError('ChatRoom');
18
21
  export const ChatRoomOwnershipError = makeOwnershipError('ChatRoom');
19
- export const ChatMessageNotFoundError = makeNotFoundError('ChatMessage');
20
- export const ChatMessageOwnershipError = makeOwnershipError('ChatMessage');
21
22
  export const ChatMessageBlockedError = createDomainError('ChatMessageBlockedError', () => 'Message blocked: it contains prohibited language');
22
23
  export const ChatSelfBlockError = createDomainError('ChatSelfBlockError', () => 'You cannot block yourself');
23
24
  export const ChatSelfIgnoreError = createDomainError('ChatSelfIgnoreError', () => 'You cannot ignore yourself');
24
25
  export const ChatRoomSlugConflictError = makeConflictError('ChatRoomSlugConflictError', 'A room with this slug already exists');
25
26
  export const ChatRoomLimitReachedError = makeConflictError('ChatRoomLimitReachedError', 'Private room limit reached');
26
- export const ChatRoomLastModeratorError = makeConflictError('ChatRoomLastModeratorError', 'Cannot leave: you are the sole moderator of this room');
27
- export const ChatRoomJoinCodeNotFoundError = createDomainError('ChatRoomJoinCodeNotFoundError', (code) => `No room found with join code: ${code}`);
28
- export const ChatRoomBannedError = createDomainError('ChatRoomBannedError', (roomId) => `You are banned from room: ${roomId}`);
29
- export const ChatRoomNotMemberError = createDomainError('ChatRoomNotMemberError', (roomId) => `You are not a member of room: ${roomId}`);
30
- export const ChatRoomNotModeratorError = createDomainError('ChatRoomNotModeratorError', (roomId) => `You are not a moderator of room: ${roomId}`);
31
- export const ChatRoomSelfModerationError = createDomainError('ChatRoomSelfModerationError', () => 'You cannot kick or ban yourself');
27
+ export const ChatRoomRuleNotFoundError = createDomainError('ChatRoomRuleNotFoundError', (id) => `Chat room rule not found: ${id}`);
28
+ export const ChatRoomConfigurationNotFoundError = createDomainError('ChatRoomConfigurationNotFoundError', (id) => `Chat room configuration not found: ${id}`);
29
+ const CHAT_MODERATOR_ROLES = ['moderator', 'owner'];
32
30
  function gateContent(content) {
33
31
  const result = moderateContent(content);
34
32
  if (!result.ok) {
@@ -38,11 +36,17 @@ function gateContent(content) {
38
36
  }
39
37
  function toRoom(record) {
40
38
  const { deletedAt: _deletedAt, ...room } = record;
41
- return serializeRow(room, { dateFields: ['createdAt'] });
39
+ return serializeRow({ ...room, isBanned: false, bannedUntil: null }, { dateFields: ['createdAt', 'bannedUntil'] });
42
40
  }
43
41
  function toMessage(record) {
44
42
  return serializeRow(record, { dateFields: ['createdAt'] });
45
43
  }
44
+ function toRule(record) {
45
+ return serializeRow(record, { dateFields: ['createdAt', 'updatedAt'] });
46
+ }
47
+ function toConfiguration(record) {
48
+ return serializeRow(record, { dateFields: ['createdAt', 'updatedAt'] });
49
+ }
46
50
  function toSystemMessage(record) {
47
51
  const message = toMessage(record);
48
52
  return { ...message, actorId: message.userId };
@@ -82,12 +86,16 @@ export class ChatService {
82
86
  events;
83
87
  transport;
84
88
  directory;
89
+ audit;
90
+ moderation;
85
91
  identityReader;
86
- constructor(drizzle, events, transport, directory, identityReader) {
92
+ constructor(drizzle, events, transport, directory, audit, moderation, identityReader) {
87
93
  this.drizzle = drizzle;
88
94
  this.events = events;
89
95
  this.transport = transport;
90
96
  this.directory = directory;
97
+ this.audit = audit;
98
+ this.moderation = moderation;
91
99
  this.identityReader = identityReader;
92
100
  }
93
101
  subscribeMessages(roomId, listener, viewerId, connectionId = randomUUID()) {
@@ -120,7 +128,7 @@ export class ChatService {
120
128
  else {
121
129
  deliver(message);
122
130
  }
123
- });
131
+ }, viewerId);
124
132
  return () => {
125
133
  unsubscribe();
126
134
  presence?.leave(channel, presenceMemberId, connectionId);
@@ -169,6 +177,27 @@ export class ChatService {
169
177
  if (!room) {
170
178
  throw new ChatRoomNotFoundError(roomId);
171
179
  }
180
+ if (viewerId) {
181
+ const now = new Date();
182
+ const [roomBan] = await this.drizzle.db
183
+ .select({ expiresAt: chatRoomBan.expiresAt })
184
+ .from(chatRoomBan)
185
+ .where(and(eq(chatRoomBan.roomId, roomId), eq(chatRoomBan.userId, viewerId), isNull(chatRoomBan.liftedAt), or(isNull(chatRoomBan.expiresAt), gt(chatRoomBan.expiresAt, now))))
186
+ .limit(1);
187
+ if (roomBan) {
188
+ throw new ChatPlayerBannedError(roomBan.expiresAt);
189
+ }
190
+ const [platformBan] = await this.drizzle.db
191
+ .select({ expiresAt: chatPlatformBan.expiresAt })
192
+ .from(chatPlatformBan)
193
+ .where(and(eq(chatPlatformBan.userId, viewerId), isNull(chatPlatformBan.liftedAt), or(isNull(chatPlatformBan.expiresAt), gt(chatPlatformBan.expiresAt, now)), room.isPublic
194
+ ? or(eq(chatPlatformBan.scope, '__all'), eq(chatPlatformBan.scope, '__all_public'), and(eq(chatPlatformBan.scope, 'room'), eq(chatPlatformBan.roomId, roomId)))
195
+ : or(eq(chatPlatformBan.scope, '__all'), and(eq(chatPlatformBan.scope, 'room'), eq(chatPlatformBan.roomId, roomId)))))
196
+ .limit(1);
197
+ if (platformBan) {
198
+ throw new ChatPlayerBannedError(platformBan.expiresAt);
199
+ }
200
+ }
172
201
  if (!room.isPublic) {
173
202
  if (!viewerId) {
174
203
  throw new ChatRoomNotMemberError(roomId);
@@ -184,6 +213,19 @@ export class ChatService {
184
213
  }
185
214
  return room;
186
215
  }
216
+ async verifyGlobalAccess(viewerId) {
217
+ if (!viewerId) {
218
+ return;
219
+ }
220
+ const [platformBan] = await this.drizzle.db
221
+ .select({ expiresAt: chatPlatformBan.expiresAt })
222
+ .from(chatPlatformBan)
223
+ .where(and(eq(chatPlatformBan.userId, viewerId), isNull(chatPlatformBan.liftedAt), or(isNull(chatPlatformBan.expiresAt), gt(chatPlatformBan.expiresAt, new Date())), or(eq(chatPlatformBan.scope, '__global'), eq(chatPlatformBan.scope, '__all_public'), eq(chatPlatformBan.scope, '__all'))))
224
+ .limit(1);
225
+ if (platformBan) {
226
+ throw new ChatPlayerBannedError(platformBan.expiresAt);
227
+ }
228
+ }
187
229
  async listRooms(viewerId) {
188
230
  const publicRooms = await this.drizzle.db
189
231
  .select()
@@ -197,20 +239,59 @@ export class ChatService {
197
239
  .select({ roomId: chatRoomMember.roomId })
198
240
  .from(chatRoomMember)
199
241
  .where(eq(chatRoomMember.userId, viewerId));
200
- if (memberIds.length === 0) {
201
- return publicRooms.map(toRoom);
242
+ const now = new Date();
243
+ const bans = await this.drizzle.db
244
+ .select({ roomId: chatRoomBan.roomId, expiresAt: chatRoomBan.expiresAt })
245
+ .from(chatRoomBan)
246
+ .where(and(eq(chatRoomBan.userId, viewerId), isNull(chatRoomBan.liftedAt), or(isNull(chatRoomBan.expiresAt), gt(chatRoomBan.expiresAt, now))));
247
+ const roomBanById = new Map(bans.map((ban) => [ban.roomId, ban.expiresAt]));
248
+ const bannedPrivateIds = bans.map((ban) => ban.roomId);
249
+ const platformBans = await this.drizzle.db
250
+ .select({
251
+ scope: chatPlatformBan.scope,
252
+ roomId: chatPlatformBan.roomId,
253
+ expiresAt: chatPlatformBan.expiresAt,
254
+ })
255
+ .from(chatPlatformBan)
256
+ .where(and(eq(chatPlatformBan.userId, viewerId), isNull(chatPlatformBan.liftedAt), or(isNull(chatPlatformBan.expiresAt), gt(chatPlatformBan.expiresAt, now))));
257
+ const allPublicBan = platformBans.find((ban) => ban.scope === '__all_public' || ban.scope === '__all');
258
+ const allPrivateBan = platformBans.find((ban) => ban.scope === '__all');
259
+ const globalBan = platformBans.find((ban) => ban.scope === '__global');
260
+ for (const ban of platformBans) {
261
+ if (ban.scope === 'room' && ban.roomId) {
262
+ roomBanById.set(ban.roomId, ban.expiresAt);
263
+ }
202
264
  }
203
265
  const privateRooms = await this.drizzle.db
204
266
  .select()
205
267
  .from(chatRoom)
206
268
  .where(and(eq(chatRoom.isPublic, false), isNull(chatRoom.deletedAt), inArray(chatRoom.id, memberIds.map((r) => r.roomId))))
207
269
  .orderBy(asc(chatRoom.createdAt));
208
- return [...publicRooms, ...privateRooms].map(toRoom);
270
+ const privateBannedRooms = bannedPrivateIds.length
271
+ ? await this.drizzle.db
272
+ .select()
273
+ .from(chatRoom)
274
+ .where(and(eq(chatRoom.isPublic, false), isNull(chatRoom.deletedAt), inArray(chatRoom.id, bannedPrivateIds)))
275
+ : [];
276
+ const uniqueRooms = [...publicRooms, ...privateRooms, ...privateBannedRooms].filter((room, index, rows) => rows.findIndex((candidate) => candidate.id === room.id) === index);
277
+ return uniqueRooms.map((room) => {
278
+ const roomBanUntil = roomBanById.get(room.id) ?? null;
279
+ const platformBanUntil = room.isPublic
280
+ ? (allPublicBan?.expiresAt ?? null)
281
+ : (allPrivateBan?.expiresAt ?? null);
282
+ return serializeRow({
283
+ ...room,
284
+ isBanned: room.slug === GLOBAL_CHAT_ROOM_ID
285
+ ? Boolean(globalBan || allPublicBan)
286
+ : roomBanById.has(room.id) || Boolean(room.isPublic ? allPublicBan : allPrivateBan),
287
+ bannedUntil: roomBanById.has(room.id) ? roomBanUntil : platformBanUntil,
288
+ }, { dateFields: ['createdAt', 'bannedUntil'] });
289
+ });
209
290
  }
210
- async listAdminRooms({ page, limit, sortBy, sortOrder, }) {
291
+ async listAdminRooms({ page, limit, name, sortBy, sortOrder, }) {
211
292
  const dir = sortOrder === 'asc' ? asc : desc;
212
293
  const col = sortBy === 'name' ? chatRoom.name : chatRoom.createdAt;
213
- const where = and(eq(chatRoom.isPublic, true), isNull(chatRoom.deletedAt));
294
+ const where = and(eq(chatRoom.isPublic, true), isNull(chatRoom.deletedAt), name ? ilike(chatRoom.name, `%${name}%`) : undefined);
214
295
  const [rows, [{ n }]] = await Promise.all([
215
296
  this.drizzle.db
216
297
  .select()
@@ -223,13 +304,205 @@ export class ChatService {
223
304
  ]);
224
305
  return { items: rows.map(toRoom), total: Number(n), page, limit };
225
306
  }
307
+ async listModeratedRooms({ viewerId, page, limit, name, sortBy, sortOrder, }) {
308
+ const dir = sortOrder === 'asc' ? asc : desc;
309
+ const col = sortBy === 'name' ? chatRoom.name : chatRoom.createdAt;
310
+ const where = and(eq(chatRoomMember.userId, viewerId), inArray(chatRoomMember.role, ['owner', 'moderator']), isNull(chatRoom.deletedAt), name ? ilike(chatRoom.name, `%${name}%`) : undefined);
311
+ const [rows, [{ n }]] = await Promise.all([
312
+ this.drizzle.db
313
+ .select({ room: chatRoom })
314
+ .from(chatRoomMember)
315
+ .innerJoin(chatRoom, eq(chatRoom.id, chatRoomMember.roomId))
316
+ .where(where)
317
+ .orderBy(dir(col))
318
+ .limit(limit)
319
+ .offset(pageToOffset(page, limit)),
320
+ this.drizzle.db
321
+ .select({ n: count() })
322
+ .from(chatRoomMember)
323
+ .innerJoin(chatRoom, eq(chatRoom.id, chatRoomMember.roomId))
324
+ .where(where),
325
+ ]);
326
+ return { items: rows.map(({ room }) => toRoom(room)), total: Number(n), page, limit };
327
+ }
226
328
  async getRoom({ roomId, viewerId }) {
227
329
  const room = await this.verifyRoomAccess(roomId, viewerId);
228
330
  return toRoom(room);
229
331
  }
332
+ async assertRoomModerator(roomId, actorId, targetId) {
333
+ const [actor] = await this.drizzle.db
334
+ .select({ role: chatRoomMember.role })
335
+ .from(chatRoomMember)
336
+ .where(and(eq(chatRoomMember.roomId, roomId), eq(chatRoomMember.userId, actorId)))
337
+ .limit(1);
338
+ if (!actor || !CHAT_MODERATOR_ROLES.includes(actor.role)) {
339
+ throw new ChatRoomNotModeratorError(roomId);
340
+ }
341
+ if (targetId) {
342
+ const [target] = await this.drizzle.db
343
+ .select({ role: chatRoomMember.role })
344
+ .from(chatRoomMember)
345
+ .where(and(eq(chatRoomMember.roomId, roomId), eq(chatRoomMember.userId, targetId)))
346
+ .limit(1);
347
+ if (actor.role !== 'owner' && target && target.role !== 'member') {
348
+ throw new ChatRoomNotModeratorError(roomId);
349
+ }
350
+ }
351
+ return actor.role;
352
+ }
353
+ async listRoomRules(roomReference, viewerId) {
354
+ const isGlobalRoom = roomReference === GLOBAL_CHAT_ROOM_ID;
355
+ const [room] = await this.drizzle.db
356
+ .select({ id: chatRoom.id, slug: chatRoom.slug, isPublic: chatRoom.isPublic })
357
+ .from(chatRoom)
358
+ .where(and(isGlobalRoom ? eq(chatRoom.slug, GLOBAL_CHAT_ROOM_ID) : eq(chatRoom.id, roomReference), isNull(chatRoom.deletedAt)))
359
+ .limit(1);
360
+ if (!room) {
361
+ throw new ChatRoomNotFoundError(roomReference);
362
+ }
363
+ if (!room.isPublic) {
364
+ if (!viewerId) {
365
+ throw new ChatRoomNotMemberError(room.id);
366
+ }
367
+ const [member] = await this.drizzle.db
368
+ .select({ id: chatRoomMember.id })
369
+ .from(chatRoomMember)
370
+ .where(and(eq(chatRoomMember.roomId, room.id), eq(chatRoomMember.userId, viewerId)))
371
+ .limit(1);
372
+ if (!member) {
373
+ throw new ChatRoomNotMemberError(room.id);
374
+ }
375
+ }
376
+ const rows = await this.drizzle.db
377
+ .select()
378
+ .from(chatRoomRule)
379
+ .where(eq(chatRoomRule.roomId, room.id))
380
+ .orderBy(asc(chatRoomRule.orderNum), asc(chatRoomRule.createdAt));
381
+ return rows.map(toRule);
382
+ }
383
+ async createRoomRule({ roomId, actorId, orderNum, content, }) {
384
+ await this.assertRoomModerator(roomId, actorId);
385
+ let nextOrder = orderNum;
386
+ if (nextOrder === undefined) {
387
+ const [last] = await this.drizzle.db
388
+ .select({ orderNum: chatRoomRule.orderNum })
389
+ .from(chatRoomRule)
390
+ .where(eq(chatRoomRule.roomId, roomId))
391
+ .orderBy(desc(chatRoomRule.orderNum))
392
+ .limit(1);
393
+ nextOrder = (last?.orderNum ?? 0) + 1;
394
+ }
395
+ const [created] = await this.drizzle.db
396
+ .insert(chatRoomRule)
397
+ .values({ roomId, createdBy: actorId, orderNum: nextOrder, content })
398
+ .returning();
399
+ return toRule(created);
400
+ }
401
+ async updateRoomRule({ roomId, id, actorId, orderNum, content, }) {
402
+ await this.assertRoomModerator(roomId, actorId);
403
+ const patch = { updatedAt: new Date() };
404
+ if (orderNum !== undefined) {
405
+ patch.orderNum = orderNum;
406
+ }
407
+ if (content !== undefined) {
408
+ patch.content = content;
409
+ }
410
+ const updated = findOneOrThrow(await this.drizzle.db
411
+ .update(chatRoomRule)
412
+ .set(patch)
413
+ .where(and(eq(chatRoomRule.id, id), eq(chatRoomRule.roomId, roomId)))
414
+ .returning(), new ChatRoomRuleNotFoundError(id));
415
+ return toRule(updated);
416
+ }
417
+ async deleteRoomRule({ roomId, id, actorId, }) {
418
+ await this.assertRoomModerator(roomId, actorId);
419
+ findOneOrThrow(await this.drizzle.db
420
+ .delete(chatRoomRule)
421
+ .where(and(eq(chatRoomRule.id, id), eq(chatRoomRule.roomId, roomId)))
422
+ .returning({ id: chatRoomRule.id }), new ChatRoomRuleNotFoundError(id));
423
+ return { success: true };
424
+ }
425
+ async getRoomConfiguration(roomId, viewerId) {
426
+ await this.verifyRoomAccess(roomId, viewerId);
427
+ let [config] = await this.drizzle.db
428
+ .select()
429
+ .from(chatRoomConfiguration)
430
+ .where(eq(chatRoomConfiguration.roomId, roomId))
431
+ .limit(1);
432
+ if (!config) {
433
+ [config] = await this.drizzle.db.insert(chatRoomConfiguration).values({ roomId }).returning();
434
+ }
435
+ return toConfiguration(config);
436
+ }
437
+ async updateRoomConfiguration({ roomId, actorId, ...patch }) {
438
+ await this.assertRoomModerator(roomId, actorId);
439
+ const [config] = await this.drizzle.db
440
+ .insert(chatRoomConfiguration)
441
+ .values({ roomId, ...patch, updatedAt: new Date() })
442
+ .onConflictDoUpdate({
443
+ target: chatRoomConfiguration.roomId,
444
+ set: { ...patch, updatedAt: new Date() },
445
+ })
446
+ .returning();
447
+ return toConfiguration(config);
448
+ }
449
+ async listRoomUsers({ roomId, actorId, status, }) {
450
+ await this.assertRoomModerator(roomId, actorId);
451
+ const role = status === 'all' ? undefined : status;
452
+ const canSeeAdminUsers = await this.canSeeAdminUsers(actorId);
453
+ const members = await this.drizzle.db
454
+ .select({ member: chatRoomMember, username: user.name })
455
+ .from(chatRoomMember)
456
+ .leftJoin(user, eq(user.id, chatRoomMember.userId))
457
+ .where(and(eq(chatRoomMember.roomId, roomId), role ? eq(chatRoomMember.role, role) : undefined, canSeeAdminUsers
458
+ ? undefined
459
+ : or(isNull(user.id), notInArray(user.role, ['admin', 'super-admin']))))
460
+ .orderBy(asc(chatRoomMember.joinedAt));
461
+ const bans = await this.drizzle.db
462
+ .select()
463
+ .from(chatRoomBan)
464
+ .where(and(eq(chatRoomBan.roomId, roomId), isNull(chatRoomBan.liftedAt)));
465
+ const now = Date.now();
466
+ const activeBans = new Map(bans
467
+ .filter((ban) => !ban.expiresAt || ban.expiresAt.getTime() > now)
468
+ .map((ban) => [ban.userId, ban]));
469
+ const names = await this.directory.lookupPlayers(members.map(({ member }) => member.userId));
470
+ const nameById = new Map(names.map((name) => [name.userId, name.username]));
471
+ return members.map(({ member, username }) => {
472
+ const ban = activeBans.get(member.userId);
473
+ return serializeRow({
474
+ userId: member.userId,
475
+ username: nameById.get(member.userId) ?? username ?? null,
476
+ role: member.role,
477
+ joinedAt: member.joinedAt,
478
+ blocked: Boolean(ban),
479
+ banId: ban?.id ?? null,
480
+ banExpiresAt: ban?.expiresAt ?? null,
481
+ }, { dateFields: ['joinedAt', 'banExpiresAt'] });
482
+ });
483
+ }
484
+ async listRoomBlockedUsers({ roomId, actorId }) {
485
+ await this.assertRoomModerator(roomId, actorId);
486
+ const rows = await this.drizzle.db
487
+ .select()
488
+ .from(chatRoomBan)
489
+ .where(and(eq(chatRoomBan.roomId, roomId), isNull(chatRoomBan.liftedAt), or(isNull(chatRoomBan.expiresAt), gt(chatRoomBan.expiresAt, new Date()))))
490
+ .orderBy(desc(chatRoomBan.createdAt));
491
+ const latest = new Map();
492
+ for (const row of rows) {
493
+ if (!latest.has(row.userId)) {
494
+ latest.set(row.userId, row);
495
+ }
496
+ }
497
+ return [...latest.values()].map((row) => serializeRow(row, { dateFields: ['createdAt', 'expiresAt', 'liftedAt'] }));
498
+ }
230
499
  async getRoomMessages({ roomId, limit = DEFAULT_MESSAGE_LIMIT, before, viewerId, }) {
231
500
  await this.verifyRoomAccess(roomId, viewerId);
232
- const conditions = [eq(chatMessage.roomId, roomId), eq(chatMessage.isDeleted, false)];
501
+ const conditions = [
502
+ eq(chatMessage.roomId, roomId),
503
+ eq(chatMessage.isDeleted, false),
504
+ isNull(chatMessage.deletedAt),
505
+ ];
233
506
  if (before) {
234
507
  conditions.push(lt(chatMessage.createdAt, new Date(before)));
235
508
  }
@@ -242,6 +515,93 @@ export class ChatService {
242
515
  .limit(limit);
243
516
  return messages.map(toPublicMessage);
244
517
  }
518
+ async listAdminRoomMessages({ roomId, page, limit, senderId, playerId, includeDeleted, }) {
519
+ const roomWhere = roomId === GLOBAL_CHAT_ROOM_ID
520
+ ? and(eq(chatRoom.slug, GLOBAL_CHAT_ROOM_ID), isNull(chatRoom.deletedAt))
521
+ : and(eq(chatRoom.id, roomId), isNull(chatRoom.deletedAt));
522
+ const [room] = await this.drizzle.db
523
+ .select({ id: chatRoom.id })
524
+ .from(chatRoom)
525
+ .where(roomWhere)
526
+ .limit(1);
527
+ if (!room) {
528
+ throw new ChatRoomNotFoundError(roomId);
529
+ }
530
+ const where = and(roomId === GLOBAL_CHAT_ROOM_ID ? isNull(chatMessage.roomId) : eq(chatMessage.roomId, roomId), includeDeleted ? undefined : eq(chatMessage.isDeleted, false), senderId ? eq(chatMessage.userId, senderId) : undefined, playerId ? eq(player.id, playerId) : undefined);
531
+ const [rows, [{ n }]] = await Promise.all([
532
+ this.drizzle.db
533
+ .select({ message: chatMessage, playerId: player.id })
534
+ .from(chatMessage)
535
+ .leftJoin(player, eq(player.userId, chatMessage.userId))
536
+ .where(where)
537
+ .orderBy(desc(chatMessage.createdAt))
538
+ .limit(limit)
539
+ .offset(pageToOffset(page, limit)),
540
+ this.drizzle.db
541
+ .select({ n: count() })
542
+ .from(chatMessage)
543
+ .leftJoin(player, eq(player.userId, chatMessage.userId))
544
+ .where(where),
545
+ ]);
546
+ return {
547
+ items: rows.map(({ message, playerId }) => ({ ...toPublicMessage(message), playerId })),
548
+ total: Number(n),
549
+ page,
550
+ limit,
551
+ };
552
+ }
553
+ async listAdminMessages({ page, limit, roomId, senderId, playerId, search, includeDeleted, sortOrder, }) {
554
+ const roomCondition = roomId === GLOBAL_CHAT_ROOM_ID
555
+ ? isNull(chatMessage.roomId)
556
+ : roomId
557
+ ? eq(chatMessage.roomId, roomId)
558
+ : undefined;
559
+ const where = and(eq(chatMessage.type, 'user'), roomCondition, senderId ? eq(chatMessage.userId, senderId) : undefined, playerId ? eq(player.id, playerId) : undefined, search ? ilike(chatMessage.content, `%${search}%`) : undefined, includeDeleted
560
+ ? undefined
561
+ : and(eq(chatMessage.isDeleted, false), isNull(chatMessage.deletedAt)));
562
+ const direction = sortOrder === 'asc' ? asc : desc;
563
+ const [rows, [{ n }]] = await Promise.all([
564
+ this.drizzle.db
565
+ .select({
566
+ id: chatMessage.id,
567
+ createdAt: chatMessage.createdAt,
568
+ roomId: chatMessage.roomId,
569
+ playerId: player.id,
570
+ roomName: chatRoom.name,
571
+ content: chatMessage.content,
572
+ })
573
+ .from(chatMessage)
574
+ .leftJoin(player, eq(player.userId, chatMessage.userId))
575
+ .leftJoin(chatRoom, eq(chatRoom.id, chatMessage.roomId))
576
+ .where(where)
577
+ .orderBy(direction(chatMessage.createdAt))
578
+ .limit(limit)
579
+ .offset(pageToOffset(page, limit)),
580
+ this.drizzle.db
581
+ .select({ n: count() })
582
+ .from(chatMessage)
583
+ .leftJoin(player, eq(player.userId, chatMessage.userId))
584
+ .leftJoin(chatRoom, eq(chatRoom.id, chatMessage.roomId))
585
+ .where(where),
586
+ ]);
587
+ return {
588
+ items: rows.map((row) => {
589
+ const date = row.createdAt.toISOString();
590
+ return {
591
+ id: row.id,
592
+ date,
593
+ roomId: row.roomId ?? GLOBAL_CHAT_ROOM_ID,
594
+ playerId: row.playerId,
595
+ roomName: row.roomName ?? 'Global',
596
+ content: row.content,
597
+ time: date.slice(11, 19),
598
+ };
599
+ }),
600
+ total: Number(n),
601
+ page,
602
+ limit,
603
+ };
604
+ }
245
605
  async appendExcludedSendersFilter(conditions, viewerId) {
246
606
  if (!viewerId) {
247
607
  return;
@@ -253,7 +613,8 @@ export class ChatService {
253
613
  }
254
614
  async sendRoomMessage({ userId, username, roomId, content, }) {
255
615
  // TODO: check RG_SELF_EXCLUSION_SERVICE before send (sealed token not yet implemented)
256
- await this.verifyRoomAccess(roomId, userId);
616
+ const room = await this.verifyRoomAccess(roomId, userId);
617
+ await this.moderation.assertCanSend(userId, roomId, room.isPublic);
257
618
  const safeContent = gateContent(content);
258
619
  const displayName = await this.resolveDisplayName(userId, username);
259
620
  const [record] = await this.drizzle.db
@@ -274,17 +635,30 @@ export class ChatService {
274
635
  publishChatEvent(this.transport, roomId, message);
275
636
  return message;
276
637
  }
277
- async deleteMessage(id, userId) {
638
+ async deleteMessage(id, userId, meta) {
278
639
  const message = findOneOrThrow(await this.drizzle.db.select().from(chatMessage).where(eq(chatMessage.id, id)), new ChatMessageNotFoundError(id));
279
- assertOwnership(message.userId, userId, new ChatMessageOwnershipError());
280
- await this.drizzle.db
281
- .update(chatMessage)
282
- .set({ isDeleted: true })
283
- .where(eq(chatMessage.id, id));
284
- return { success: true };
640
+ let roomId = message.roomId;
641
+ if (roomId === null) {
642
+ const [globalRoom] = await this.drizzle.db
643
+ .select({ id: chatRoom.id })
644
+ .from(chatRoom)
645
+ .where(and(eq(chatRoom.slug, '__global'), isNull(chatRoom.deletedAt)))
646
+ .limit(1);
647
+ if (!globalRoom) {
648
+ throw new ChatRoomNotFoundError('__global');
649
+ }
650
+ roomId = globalRoom.id;
651
+ }
652
+ await this.assertRoomModerator(roomId, userId);
653
+ return this.moderation.deleteMessage(id, userId, meta, 'player');
285
654
  }
286
655
  async getGlobalMessages(limit = DEFAULT_MESSAGE_LIMIT, viewerId) {
287
- const conditions = [isNull(chatMessage.roomId), eq(chatMessage.isDeleted, false)];
656
+ await this.verifyGlobalAccess(viewerId);
657
+ const conditions = [
658
+ isNull(chatMessage.roomId),
659
+ eq(chatMessage.isDeleted, false),
660
+ isNull(chatMessage.deletedAt),
661
+ ];
288
662
  await this.appendExcludedSendersFilter(conditions, viewerId);
289
663
  const messages = await this.drizzle.db
290
664
  .select()
@@ -296,6 +670,7 @@ export class ChatService {
296
670
  }
297
671
  async sendGlobalMessage(userId, username, content) {
298
672
  // TODO: check RG_SELF_EXCLUSION_SERVICE before send (sealed token not yet implemented)
673
+ await this.moderation.assertCanSend(userId, null);
299
674
  const safeContent = gateContent(content);
300
675
  const displayName = await this.resolveDisplayName(userId, username);
301
676
  const [record] = await this.drizzle.db
@@ -501,20 +876,29 @@ export class ChatService {
501
876
  return [...(await this.excludedSenderIdsFor(viewerId))];
502
877
  }
503
878
  async createRoom({ name, slug, category, actorId, ip, userAgent, }) {
504
- const [existing] = await this.drizzle.db
505
- .select({ id: chatRoom.id })
506
- .from(chatRoom)
507
- .where(eq(chatRoom.slug, slug))
508
- .limit(1);
509
- if (existing) {
510
- throw new ChatRoomSlugConflictError();
511
- }
512
879
  let record;
513
880
  try {
514
- [record] = await this.drizzle.db
515
- .insert(chatRoom)
516
- .values({ name, slug, category })
517
- .returning();
881
+ record = await this.drizzle.db.transaction(async (tx) => {
882
+ const [existing] = await tx
883
+ .select({ id: chatRoom.id })
884
+ .from(chatRoom)
885
+ .where(eq(chatRoom.slug, slug))
886
+ .limit(1);
887
+ if (existing) {
888
+ throw new ChatRoomSlugConflictError();
889
+ }
890
+ const [created] = await tx
891
+ .insert(chatRoom)
892
+ .values({ name, slug, category, joinCode: generateJoinCode() })
893
+ .returning();
894
+ await tx.insert(chatRoomConfiguration).values({ roomId: created.id });
895
+ if (actorId) {
896
+ await tx
897
+ .insert(chatRoomMember)
898
+ .values({ roomId: created.id, userId: actorId, role: 'owner' });
899
+ }
900
+ return created;
901
+ });
518
902
  }
519
903
  catch (error) {
520
904
  if (isUniqueConstraintViolation(error)) {
@@ -583,6 +967,18 @@ export class ChatService {
583
967
  });
584
968
  return toRoom(updated);
585
969
  }
970
+ async updatePrivateRoom({ id, name, actorId, ip, userAgent, }) {
971
+ const room = findOneOrThrow(await this.drizzle.db
972
+ .select()
973
+ .from(chatRoom)
974
+ .where(and(eq(chatRoom.id, id), eq(chatRoom.isPublic, false), isNull(chatRoom.deletedAt)))
975
+ .limit(1), new ChatRoomNotFoundError(id));
976
+ if (!room.creatorId) {
977
+ throw new ChatRoomOwnershipError();
978
+ }
979
+ assertOwnership(room.creatorId, actorId, new ChatRoomOwnershipError());
980
+ return this.updateRoom({ id, name, actorId, ip, userAgent });
981
+ }
586
982
  async deletePrivateRoom({ roomId, userId, ip, userAgent, }) {
587
983
  const room = findOneOrThrow(await this.drizzle.db
588
984
  .select()
@@ -593,14 +989,14 @@ export class ChatService {
593
989
  throw new ChatRoomOwnershipError();
594
990
  }
595
991
  assertOwnership(room.creatorId, userId, new ChatRoomOwnershipError());
596
- await this.drizzle.db
597
- .update(chatRoom)
598
- .set({ deletedAt: new Date() })
599
- .where(eq(chatRoom.id, roomId));
992
+ const deletedAt = new Date();
993
+ await this.drizzle.db.update(chatRoom).set({ deletedAt }).where(eq(chatRoom.id, roomId));
600
994
  this.events.emit('chat.private_room.deleted', {
601
995
  roomId,
602
996
  creatorId: userId,
603
997
  playerId: await this.identityReader.getPlayerIdByUserIdSafe(userId),
998
+ before: { name: room.name, slug: room.slug, category: room.category },
999
+ after: { deletedAt: deletedAt.toISOString() },
604
1000
  ip: ip ?? null,
605
1001
  userAgent: userAgent ?? null,
606
1002
  });
@@ -651,9 +1047,10 @@ export class ChatService {
651
1047
  creatorId: userId,
652
1048
  })
653
1049
  .returning();
1050
+ await t.insert(chatRoomConfiguration).values({ roomId: room.id });
654
1051
  await t
655
1052
  .insert(chatRoomMember)
656
- .values({ roomId: room.id, userId, role: 'moderator' })
1053
+ .values({ roomId: room.id, userId, role: 'owner' })
657
1054
  .onConflictDoNothing();
658
1055
  return room;
659
1056
  }));
@@ -676,150 +1073,6 @@ export class ChatService {
676
1073
  // unreachable: loop returns or rethrows on every iteration
677
1074
  throw new ChatRoomSlugConflictError();
678
1075
  }
679
- async joinRoom({ userId, joinCode, ip, userAgent, }) {
680
- const [candidate] = await this.drizzle.db
681
- .select({ id: chatRoom.id })
682
- .from(chatRoom)
683
- .where(and(eq(chatRoom.joinCode, joinCode), isNull(chatRoom.deletedAt)))
684
- .limit(1);
685
- if (!candidate) {
686
- throw new ChatRoomJoinCodeNotFoundError(joinCode);
687
- }
688
- const { room, inserted } = await this.drizzle.db.transaction((t) => withAdvisoryXactLock(t, `chat-room:${candidate.id}`, async () => {
689
- const [room] = await t
690
- .select()
691
- .from(chatRoom)
692
- .where(and(eq(chatRoom.id, candidate.id), eq(chatRoom.joinCode, joinCode), isNull(chatRoom.deletedAt)))
693
- .limit(1);
694
- if (!room) {
695
- throw new ChatRoomJoinCodeNotFoundError(joinCode);
696
- }
697
- const [ban] = await t
698
- .select({ id: chatRoomBan.id })
699
- .from(chatRoomBan)
700
- .where(and(eq(chatRoomBan.roomId, room.id), eq(chatRoomBan.userId, userId)))
701
- .limit(1);
702
- if (ban) {
703
- throw new ChatRoomBannedError(room.id);
704
- }
705
- // Idempotent: re-joining is a no-op (preserves existing role).
706
- const inserted = await t
707
- .insert(chatRoomMember)
708
- .values({ roomId: room.id, userId })
709
- .onConflictDoNothing()
710
- .returning();
711
- return { room, inserted };
712
- }));
713
- if (inserted.length > 0) {
714
- this.events.emit('chat.room.member.joined', {
715
- roomId: room.id,
716
- userId,
717
- playerId: await this.identityReader.getPlayerIdByUserIdSafe(userId),
718
- ip: ip ?? null,
719
- userAgent: userAgent ?? null,
720
- });
721
- }
722
- return toRoom(room);
723
- }
724
- async leaveRoom({ userId, roomId, ip, userAgent, }) {
725
- await this.verifyRoomAccess(roomId, userId);
726
- const removed = await this.drizzle.db.transaction((t) => withAdvisoryXactLock(t, `chat-room:${roomId}`, async () => {
727
- const [memberRow] = await t
728
- .select({ role: chatRoomMember.role })
729
- .from(chatRoomMember)
730
- .where(and(eq(chatRoomMember.roomId, roomId), eq(chatRoomMember.userId, userId)))
731
- .limit(1);
732
- if (memberRow?.role === 'moderator') {
733
- const [{ modCount }] = await t
734
- .select({ modCount: count() })
735
- .from(chatRoomMember)
736
- .where(and(eq(chatRoomMember.roomId, roomId), eq(chatRoomMember.role, 'moderator')));
737
- if (Number(modCount) <= 1) {
738
- throw new ChatRoomLastModeratorError();
739
- }
740
- }
741
- return t
742
- .delete(chatRoomMember)
743
- .where(and(eq(chatRoomMember.roomId, roomId), eq(chatRoomMember.userId, userId)))
744
- .returning();
745
- }));
746
- if (removed.length > 0) {
747
- this.events.emit('chat.room.member.left', {
748
- roomId,
749
- userId,
750
- playerId: await this.identityReader.getPlayerIdByUserIdSafe(userId),
751
- ip: ip ?? null,
752
- userAgent: userAgent ?? null,
753
- });
754
- }
755
- return { success: true };
756
- }
757
- async kickMember({ moderatorId, roomId, userId, ip, userAgent, }) {
758
- if (moderatorId === userId) {
759
- throw new ChatRoomSelfModerationError();
760
- }
761
- await this.verifyRoomAccess(roomId, moderatorId);
762
- const removed = await this.drizzle.db.transaction(async (t) => {
763
- const [mod] = await t
764
- .select({ role: chatRoomMember.role })
765
- .from(chatRoomMember)
766
- .where(and(eq(chatRoomMember.roomId, roomId), eq(chatRoomMember.userId, moderatorId)))
767
- .limit(1);
768
- if (!mod || mod.role !== 'moderator') {
769
- throw new ChatRoomNotModeratorError(roomId);
770
- }
771
- return t
772
- .delete(chatRoomMember)
773
- .where(and(eq(chatRoomMember.roomId, roomId), eq(chatRoomMember.userId, userId)))
774
- .returning();
775
- });
776
- if (removed.length > 0) {
777
- this.events.emit('chat.room.member.kicked', {
778
- roomId,
779
- userId,
780
- kickedBy: moderatorId,
781
- playerId: await this.identityReader.getPlayerIdByUserIdSafe(moderatorId),
782
- ip: ip ?? null,
783
- userAgent: userAgent ?? null,
784
- });
785
- }
786
- await this.transport.revokeClient?.(userId);
787
- return { success: true };
788
- }
789
- async banMember({ moderatorId, roomId, userId, ip, userAgent, }) {
790
- if (moderatorId === userId) {
791
- throw new ChatRoomSelfModerationError();
792
- }
793
- await this.verifyRoomAccess(roomId, moderatorId);
794
- // Idempotent: banning an already-banned user is a no-op.
795
- await this.drizzle.db.transaction((t) => withAdvisoryXactLock(t, `chat-room:${roomId}`, async () => {
796
- const [mod] = await t
797
- .select({ role: chatRoomMember.role })
798
- .from(chatRoomMember)
799
- .where(and(eq(chatRoomMember.roomId, roomId), eq(chatRoomMember.userId, moderatorId)))
800
- .limit(1);
801
- if (!mod || mod.role !== 'moderator') {
802
- throw new ChatRoomNotModeratorError(roomId);
803
- }
804
- await t
805
- .insert(chatRoomBan)
806
- .values({ roomId, userId, bannedBy: moderatorId })
807
- .onConflictDoNothing();
808
- await t
809
- .delete(chatRoomMember)
810
- .where(and(eq(chatRoomMember.roomId, roomId), eq(chatRoomMember.userId, userId)));
811
- }));
812
- this.events.emit('chat.room.member.banned', {
813
- roomId,
814
- userId,
815
- bannedBy: moderatorId,
816
- playerId: await this.identityReader.getPlayerIdByUserIdSafe(moderatorId),
817
- ip: ip ?? null,
818
- userAgent: userAgent ?? null,
819
- });
820
- await this.transport.revokeClient?.(userId);
821
- return { success: true };
822
- }
823
1076
  async postSystemMessage(args) {
824
1077
  const db = args.tx ?? this.drizzle.db;
825
1078
  const [record] = await db
@@ -858,18 +1111,31 @@ export class ChatService {
858
1111
  }
859
1112
  async listRoomMembers({ roomId, viewerId }) {
860
1113
  await this.verifyRoomAccess(roomId, viewerId);
1114
+ const canSeeAdminUsers = viewerId ? await this.canSeeAdminUsers(viewerId) : false;
861
1115
  const members = await this.drizzle.db
862
1116
  .select({
863
1117
  userId: chatRoomMember.userId,
864
1118
  role: chatRoomMember.role,
865
1119
  joinedAt: chatRoomMember.joinedAt,
1120
+ username: user.name,
866
1121
  })
867
1122
  .from(chatRoomMember)
868
- .where(eq(chatRoomMember.roomId, roomId))
1123
+ .leftJoin(user, eq(user.id, chatRoomMember.userId))
1124
+ .where(and(eq(chatRoomMember.roomId, roomId), canSeeAdminUsers
1125
+ ? undefined
1126
+ : or(isNull(user.id), notInArray(user.role, ['admin', 'super-admin']))))
869
1127
  .orderBy(asc(chatRoomMember.joinedAt));
870
1128
  const summaries = await this.directory.lookupPlayers(members.map((m) => m.userId));
871
1129
  const usernameByUserId = new Map(summaries.map((s) => [s.userId, s.username]));
872
- return members.map((m) => serializeRow({ ...m, username: usernameByUserId.get(m.userId) ?? null }, { dateFields: ['joinedAt'] }));
1130
+ return members.map((m) => serializeRow({ ...m, username: usernameByUserId.get(m.userId) ?? m.username ?? null }, { dateFields: ['joinedAt'] }));
1131
+ }
1132
+ async canSeeAdminUsers(viewerId) {
1133
+ const [viewer] = await this.drizzle.db
1134
+ .select({ role: user.role })
1135
+ .from(user)
1136
+ .where(eq(user.id, viewerId))
1137
+ .limit(1);
1138
+ return viewer?.role === 'admin' || viewer?.role === 'super-admin';
873
1139
  }
874
1140
  }
875
1141
  //# sourceMappingURL=chat.service.js.map