@openora/core 0.4.1-canary.66 → 0.4.1-canary.68

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 (213) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/audit/__tests__/audit.service.int.test.js +202 -2
  3. package/dist/audit/__tests__/audit.service.int.test.js.map +1 -1
  4. package/dist/audit/plugin.d.ts +2 -0
  5. package/dist/audit/plugin.d.ts.map +1 -1
  6. package/dist/audit/plugin.js +76 -34
  7. package/dist/audit/plugin.js.map +1 -1
  8. package/dist/casino/gaming/__tests__/gaming.service.int.test.js +4 -3
  9. package/dist/casino/gaming/__tests__/gaming.service.int.test.js.map +1 -1
  10. package/dist/casino/gaming/plugin.d.ts +1 -1
  11. package/dist/casino/gaming/plugin.d.ts.map +1 -1
  12. package/dist/casino/gaming/plugin.js +3 -3
  13. package/dist/casino/gaming/plugin.js.map +1 -1
  14. package/dist/casino/gaming/service/gaming.service.d.ts +3 -2
  15. package/dist/casino/gaming/service/gaming.service.d.ts.map +1 -1
  16. package/dist/casino/gaming/service/gaming.service.js +9 -2
  17. package/dist/casino/gaming/service/gaming.service.js.map +1 -1
  18. package/dist/compliance/__tests__/compliance.service.int.test.js +4 -3
  19. package/dist/compliance/__tests__/compliance.service.int.test.js.map +1 -1
  20. package/dist/compliance/__tests__/kyc-admin.router.int.test.js +2 -1
  21. package/dist/compliance/__tests__/kyc-admin.router.int.test.js.map +1 -1
  22. package/dist/compliance/__tests__/kyc.service.int.test.js +2 -1
  23. package/dist/compliance/__tests__/kyc.service.int.test.js.map +1 -1
  24. package/dist/compliance/__tests__/rg.router.int.test.js +4 -2
  25. package/dist/compliance/__tests__/rg.router.int.test.js.map +1 -1
  26. package/dist/compliance/__tests__/rg.service.int.test.js +4 -2
  27. package/dist/compliance/__tests__/rg.service.int.test.js.map +1 -1
  28. package/dist/compliance/plugin.d.ts.map +1 -1
  29. package/dist/compliance/plugin.js +4 -2
  30. package/dist/compliance/plugin.js.map +1 -1
  31. package/dist/compliance/service/compliance.service.d.ts +3 -2
  32. package/dist/compliance/service/compliance.service.d.ts.map +1 -1
  33. package/dist/compliance/service/compliance.service.js +5 -1
  34. package/dist/compliance/service/compliance.service.js.map +1 -1
  35. package/dist/compliance/service/kyc.service.d.ts +3 -1
  36. package/dist/compliance/service/kyc.service.d.ts.map +1 -1
  37. package/dist/compliance/service/kyc.service.js +6 -2
  38. package/dist/compliance/service/kyc.service.js.map +1 -1
  39. package/dist/compliance/service/rg.service.d.ts +3 -1
  40. package/dist/compliance/service/rg.service.d.ts.map +1 -1
  41. package/dist/compliance/service/rg.service.js +7 -0
  42. package/dist/compliance/service/rg.service.js.map +1 -1
  43. package/dist/contracts/adapters/identity-reader.d.ts +2 -0
  44. package/dist/contracts/adapters/identity-reader.d.ts.map +1 -1
  45. package/dist/contracts/adapters/identity-reader.js.map +1 -1
  46. package/dist/contracts/schemas/events.d.ts +63 -0
  47. package/dist/contracts/schemas/events.d.ts.map +1 -1
  48. package/dist/contracts/schemas/events.js +140 -24
  49. package/dist/contracts/schemas/events.js.map +1 -1
  50. package/dist/engagement/chat/__tests__/chat.service.int.test.js +8 -3
  51. package/dist/engagement/chat/__tests__/chat.service.int.test.js.map +1 -1
  52. package/dist/engagement/chat/plugin.d.ts.map +1 -1
  53. package/dist/engagement/chat/plugin.js +2 -2
  54. package/dist/engagement/chat/plugin.js.map +1 -1
  55. package/dist/engagement/chat/service/chat.service.d.ts +3 -2
  56. package/dist/engagement/chat/service/chat.service.d.ts.map +1 -1
  57. package/dist/engagement/chat/service/chat.service.js +18 -1
  58. package/dist/engagement/chat/service/chat.service.js.map +1 -1
  59. package/dist/engagement/contracts.d.ts +1 -0
  60. package/dist/engagement/contracts.d.ts.map +1 -1
  61. package/dist/engagement/contracts.js +1 -0
  62. package/dist/engagement/contracts.js.map +1 -1
  63. package/dist/engagement/index.d.ts +1 -0
  64. package/dist/engagement/index.d.ts.map +1 -1
  65. package/dist/engagement/index.js +1 -0
  66. package/dist/engagement/index.js.map +1 -1
  67. package/dist/engagement/notifications/__tests__/notifications.service.int.test.js +34 -0
  68. package/dist/engagement/notifications/__tests__/notifications.service.int.test.js.map +1 -1
  69. package/dist/engagement/notifications/contract/index.d.ts +9 -1
  70. package/dist/engagement/notifications/contract/index.d.ts.map +1 -1
  71. package/dist/engagement/notifications/contract/index.js +2 -0
  72. package/dist/engagement/notifications/contract/index.js.map +1 -1
  73. package/dist/engagement/notifications/plugin.d.ts.map +1 -1
  74. package/dist/engagement/notifications/plugin.js +24 -0
  75. package/dist/engagement/notifications/plugin.js.map +1 -1
  76. package/dist/engagement/notifications/router/index.d.ts +2 -0
  77. package/dist/engagement/notifications/router/index.d.ts.map +1 -1
  78. package/dist/engagement/server.d.ts +1 -0
  79. package/dist/engagement/server.d.ts.map +1 -1
  80. package/dist/engagement/server.js +1 -0
  81. package/dist/engagement/server.js.map +1 -1
  82. package/dist/engagement/social/__tests__/social.router.int.test.d.ts +2 -0
  83. package/dist/engagement/social/__tests__/social.router.int.test.d.ts.map +1 -0
  84. package/dist/engagement/social/__tests__/social.router.int.test.js +97 -0
  85. package/dist/engagement/social/__tests__/social.router.int.test.js.map +1 -0
  86. package/dist/engagement/social/__tests__/social.service.int.test.d.ts +2 -0
  87. package/dist/engagement/social/__tests__/social.service.int.test.d.ts.map +1 -0
  88. package/dist/engagement/social/__tests__/social.service.int.test.js +233 -0
  89. package/dist/engagement/social/__tests__/social.service.int.test.js.map +1 -0
  90. package/dist/engagement/social/contract/index.d.ts +73 -0
  91. package/dist/engagement/social/contract/index.d.ts.map +1 -0
  92. package/dist/engagement/social/contract/index.js +54 -0
  93. package/dist/engagement/social/contract/index.js.map +1 -0
  94. package/dist/engagement/social/drizzle/migrations/0000_fine_jamie_braddock.sql +24 -0
  95. package/dist/engagement/social/drizzle/migrations/0001_lame_eddie_brock.sql +10 -0
  96. package/dist/engagement/social/drizzle/migrations/0002_foamy_saracen.sql +1 -0
  97. package/dist/engagement/social/drizzle/migrations/meta/0000_snapshot.json +232 -0
  98. package/dist/engagement/social/drizzle/migrations/meta/0001_snapshot.json +129 -0
  99. package/dist/engagement/social/drizzle/migrations/meta/0002_snapshot.json +134 -0
  100. package/dist/engagement/social/drizzle/migrations/meta/_journal.json +27 -0
  101. package/dist/engagement/social/drizzle.config.d.ts +4 -0
  102. package/dist/engagement/social/drizzle.config.d.ts.map +1 -0
  103. package/dist/engagement/social/drizzle.config.js +21 -0
  104. package/dist/engagement/social/drizzle.config.js.map +1 -0
  105. package/dist/engagement/social/index.d.ts +3 -0
  106. package/dist/engagement/social/index.d.ts.map +1 -0
  107. package/dist/engagement/social/index.js +6 -0
  108. package/dist/engagement/social/index.js.map +1 -0
  109. package/dist/engagement/social/migrate.d.ts +5 -0
  110. package/dist/engagement/social/migrate.d.ts.map +1 -0
  111. package/dist/engagement/social/migrate.js +17 -0
  112. package/dist/engagement/social/migrate.js.map +1 -0
  113. package/dist/engagement/social/plugin.d.ts +111 -0
  114. package/dist/engagement/social/plugin.d.ts.map +1 -0
  115. package/dist/engagement/social/plugin.js +16 -0
  116. package/dist/engagement/social/plugin.js.map +1 -0
  117. package/dist/engagement/social/router/index.d.ts +31 -0
  118. package/dist/engagement/social/router/index.d.ts.map +1 -0
  119. package/dist/engagement/social/router/index.js +31 -0
  120. package/dist/engagement/social/router/index.js.map +1 -0
  121. package/dist/engagement/social/schema/index.d.ts +128 -0
  122. package/dist/engagement/social/schema/index.d.ts.map +1 -0
  123. package/dist/engagement/social/schema/index.js +36 -0
  124. package/dist/engagement/social/schema/index.js.map +1 -0
  125. package/dist/engagement/social/service/social.service.d.ts +39 -0
  126. package/dist/engagement/social/service/social.service.d.ts.map +1 -0
  127. package/dist/engagement/social/service/social.service.js +251 -0
  128. package/dist/engagement/social/service/social.service.js.map +1 -0
  129. package/dist/iam/__tests__/iam.service.int.test.js +42 -38
  130. package/dist/iam/__tests__/iam.service.int.test.js.map +1 -1
  131. package/dist/iam/plugin.d.ts.map +1 -1
  132. package/dist/iam/plugin.js +2 -2
  133. package/dist/iam/plugin.js.map +1 -1
  134. package/dist/iam/service/iam.service.d.ts +3 -2
  135. package/dist/iam/service/iam.service.d.ts.map +1 -1
  136. package/dist/iam/service/iam.service.js +24 -10
  137. package/dist/iam/service/iam.service.js.map +1 -1
  138. package/dist/pam/identity/__tests__/identity.rate-limit.int.test.js +6 -2
  139. package/dist/pam/identity/__tests__/identity.rate-limit.int.test.js.map +1 -1
  140. package/dist/pam/identity/__tests__/identity.service.int.test.js +3 -2
  141. package/dist/pam/identity/__tests__/identity.service.int.test.js.map +1 -1
  142. package/dist/pam/identity/__tests__/login-enforcement.service.int.test.js +8 -3
  143. package/dist/pam/identity/__tests__/login-enforcement.service.int.test.js.map +1 -1
  144. package/dist/pam/identity/adapters/identity-reader.service.d.ts +1 -0
  145. package/dist/pam/identity/adapters/identity-reader.service.d.ts.map +1 -1
  146. package/dist/pam/identity/adapters/identity-reader.service.js +11 -0
  147. package/dist/pam/identity/adapters/identity-reader.service.js.map +1 -1
  148. package/dist/pam/identity/plugin.d.ts.map +1 -1
  149. package/dist/pam/identity/plugin.js +16 -3
  150. package/dist/pam/identity/plugin.js.map +1 -1
  151. package/dist/pam/identity/service/identity.service.d.ts +4 -2
  152. package/dist/pam/identity/service/identity.service.d.ts.map +1 -1
  153. package/dist/pam/identity/service/identity.service.js +81 -31
  154. package/dist/pam/identity/service/identity.service.js.map +1 -1
  155. package/dist/pam/identity/service/phone-login.service.d.ts.map +1 -1
  156. package/dist/pam/identity/service/phone-login.service.js +16 -6
  157. package/dist/pam/identity/service/phone-login.service.js.map +1 -1
  158. package/dist/pam/identity/service/session.service.d.ts +4 -2
  159. package/dist/pam/identity/service/session.service.d.ts.map +1 -1
  160. package/dist/pam/identity/service/session.service.js +5 -1
  161. package/dist/pam/identity/service/session.service.js.map +1 -1
  162. package/dist/pam/player-management/__tests__/kyc-status-writer.int.test.js +2 -1
  163. package/dist/pam/player-management/__tests__/kyc-status-writer.int.test.js.map +1 -1
  164. package/dist/pam/player-management/__tests__/player.service.int.test.js +1 -0
  165. package/dist/pam/player-management/__tests__/player.service.int.test.js.map +1 -1
  166. package/dist/pam/player-management/service/kyc-status-writer.d.ts.map +1 -1
  167. package/dist/pam/player-management/service/kyc-status-writer.js +2 -1
  168. package/dist/pam/player-management/service/kyc-status-writer.js.map +1 -1
  169. package/dist/pam/player-management/service/player.service.d.ts.map +1 -1
  170. package/dist/pam/player-management/service/player.service.js +1 -0
  171. package/dist/pam/player-management/service/player.service.js.map +1 -1
  172. package/dist/pam/tag/__tests__/tag-evaluation.service.int.test.js +57 -13
  173. package/dist/pam/tag/__tests__/tag-evaluation.service.int.test.js.map +1 -1
  174. package/dist/pam/tag/service/tag.service.d.ts.map +1 -1
  175. package/dist/pam/tag/service/tag.service.js +25 -0
  176. package/dist/pam/tag/service/tag.service.js.map +1 -1
  177. package/dist/server/auth/admin-guard.d.ts +3 -2
  178. package/dist/server/auth/admin-guard.d.ts.map +1 -1
  179. package/dist/server/auth/admin-guard.js +28 -9
  180. package/dist/server/auth/admin-guard.js.map +1 -1
  181. package/dist/server/kernel/__tests__/event-bus.int.test.js +9 -5
  182. package/dist/server/kernel/__tests__/event-bus.int.test.js.map +1 -1
  183. package/dist/server/runtime/create-app.d.ts.map +1 -1
  184. package/dist/server/runtime/create-app.js +4 -2
  185. package/dist/server/runtime/create-app.js.map +1 -1
  186. package/dist/testing/mock.d.ts +2 -1
  187. package/dist/testing/mock.d.ts.map +1 -1
  188. package/dist/testing/mock.js +8 -0
  189. package/dist/testing/mock.js.map +1 -1
  190. package/dist/wallet/__tests__/wallet-auto-rule.router.int.test.js +2 -1
  191. package/dist/wallet/__tests__/wallet-auto-rule.router.int.test.js.map +1 -1
  192. package/dist/wallet/__tests__/wallet-auto-withdrawal-config.router.int.test.js +4 -2
  193. package/dist/wallet/__tests__/wallet-auto-withdrawal-config.router.int.test.js.map +1 -1
  194. package/dist/wallet/__tests__/wallet-auto-withdrawal.int.test.js +5 -2
  195. package/dist/wallet/__tests__/wallet-auto-withdrawal.int.test.js.map +1 -1
  196. package/dist/wallet/__tests__/wallet-kyc-gate.int.test.js +4 -2
  197. package/dist/wallet/__tests__/wallet-kyc-gate.int.test.js.map +1 -1
  198. package/dist/wallet/__tests__/wallet-webhook.router.int.test.js +4 -2
  199. package/dist/wallet/__tests__/wallet-webhook.router.int.test.js.map +1 -1
  200. package/dist/wallet/__tests__/wallet.rate-limit.int.test.js +4 -2
  201. package/dist/wallet/__tests__/wallet.rate-limit.int.test.js.map +1 -1
  202. package/dist/wallet/__tests__/wallet.router.int.test.js +2 -1
  203. package/dist/wallet/__tests__/wallet.router.int.test.js.map +1 -1
  204. package/dist/wallet/__tests__/wallet.service.int.test.js +4 -2
  205. package/dist/wallet/__tests__/wallet.service.int.test.js.map +1 -1
  206. package/dist/wallet/plugin.d.ts.map +1 -1
  207. package/dist/wallet/plugin.js +2 -1
  208. package/dist/wallet/plugin.js.map +1 -1
  209. package/dist/wallet/service/wallet.service.d.ts +4 -2
  210. package/dist/wallet/service/wallet.service.d.ts.map +1 -1
  211. package/dist/wallet/service/wallet.service.js +8 -1
  212. package/dist/wallet/service/wallet.service.js.map +1 -1
  213. package/package.json +21 -1
@@ -0,0 +1,251 @@
1
+ import { and, eq, inArray, isNull, or } from 'drizzle-orm';
2
+ import { createDomainError, makeConflictError, makeNotFoundError, serializeRow, } from '@openora/core/server';
3
+ import { chatUserBlock } from '@openora/core/engagement/schema/chat';
4
+ import { player } from '@openora/core/pam/schema/profile';
5
+ import { friendship } from '../schema/index.js';
6
+ // The caller targeted themselves (SendFriendRequestInputSchema.targetUserId === callerId).
7
+ export const SelfFriendRequestError = createDomainError('SelfFriendRequestError', () => 'You cannot send a friend request to yourself');
8
+ // A genuinely nonexistent target only - never thrown for a target that exists but
9
+ // can't be requested (moderated or blocking), so an API consumer can trust that a
10
+ // NOT_FOUND means "no such player" and not have to guess.
11
+ export const FriendRequestTargetNotFoundError = makeNotFoundError('FriendRequestTarget');
12
+ // The target exists but can't receive requests right now - covers BOTH "suspended
13
+ // or closed" and "target has blocked the caller" behind the SAME code/message so
14
+ // callers still cannot distinguish a blocking account from a moderated one (the
15
+ // block itself stays undisclosed); this only stops lying that the account is
16
+ // missing entirely.
17
+ export const FriendRequestUnavailableError = makeConflictError('FRIEND_REQUEST_UNAVAILABLE', 'This player cannot receive friend requests right now');
18
+ // The caller's own chat block is disclosed because it is the caller's state.
19
+ export const BlockedBySelfError = makeConflictError('BLOCKED_BY_SELF', 'You have blocked this player - unblock them first');
20
+ export const AlreadyFriendsError = makeConflictError('ALREADY_FRIENDS', 'You are already friends with this player');
21
+ export const RequestAlreadyPendingError = makeConflictError('REQUEST_ALREADY_PENDING', 'A friend request to this player is already pending');
22
+ export const FriendRequestRefusedError = makeConflictError('FRIEND_REQUEST_REFUSED', 'This friend request was refused');
23
+ // Postgres unique-violation. friendship has exactly one unique index
24
+ // (friendship_pair_key), so any 23505 on an insert into it is unambiguous - no
25
+ // constraint-name check needed. drizzle-orm wraps the driver error in a
26
+ // DrizzleQueryError, so the pg error (and its `.code`) is on `.cause`, not the
27
+ // thrown error itself - checked against a real Postgres unique-index violation
28
+ // in social.service.int.test.ts (a mocked query builder can't catch this).
29
+ function pgErrorCode(e) {
30
+ if (typeof e !== 'object' || e === null) {
31
+ return undefined;
32
+ }
33
+ if ('code' in e && typeof e.code === 'string') {
34
+ return e.code;
35
+ }
36
+ if ('cause' in e) {
37
+ return pgErrorCode(e.cause);
38
+ }
39
+ return undefined;
40
+ }
41
+ function isUniqueConstraintViolation(e) {
42
+ return pgErrorCode(e) === '23505';
43
+ }
44
+ function toFriendshipDto(row) {
45
+ return serializeRow(row, {
46
+ dateFields: ['createdAt', 'acceptedAt', 'refusedAt'],
47
+ });
48
+ }
49
+ // Finds the single row for a pair regardless of who is requester/addressee - the
50
+ // canonical-pair unique index guarantees at most one match.
51
+ function pairCondition(userA, userB) {
52
+ return or(and(eq(friendship.requesterId, userA), eq(friendship.addresseeId, userB)), and(eq(friendship.requesterId, userB), eq(friendship.addresseeId, userA)));
53
+ }
54
+ export class SocialService {
55
+ drizzle;
56
+ events;
57
+ constructor(drizzle, events) {
58
+ this.drizzle = drizzle;
59
+ this.events = events;
60
+ }
61
+ /**
62
+ * Sends a friend request from `callerId` to `targetUserId`. The insert is
63
+ * attempted directly against the canonical-pair unique index (no pre-SELECT,
64
+ * avoiding a TOCTOU race); a unique-violation means a row for this pair already
65
+ * exists, which is resolved by re-reading it - including the mutual/simultaneous
66
+ * case where the existing row was already addressed TO the caller, which
67
+ * auto-accepts instead of conflicting. Events are emitted strictly after the
68
+ * mutating statement/transaction has committed.
69
+ */
70
+ async sendFriendRequest(callerId, targetUserId) {
71
+ if (targetUserId === callerId) {
72
+ throw new SelfFriendRequestError();
73
+ }
74
+ // Batched: caller + target resolved in one query rather than two round trips.
75
+ const players = await this.drizzle.db
76
+ .select({ userId: player.userId, displayName: player.displayName, status: player.status })
77
+ .from(player)
78
+ .where(inArray(player.userId, [callerId, targetUserId]));
79
+ const targetPlayer = players.find((p) => p.userId === targetUserId);
80
+ const callerPlayer = players.find((p) => p.userId === callerId);
81
+ if (!targetPlayer) {
82
+ throw new FriendRequestTargetNotFoundError(targetUserId);
83
+ }
84
+ if (targetPlayer.status === 'suspended' || targetPlayer.status === 'closed') {
85
+ throw new FriendRequestUnavailableError();
86
+ }
87
+ if (!callerPlayer) {
88
+ // Invariant: an authenticated caller always has a player row. Not a domain
89
+ // error (nothing the caller can act on) - fail loudly instead of masking it.
90
+ throw new Error(`Player profile missing for authenticated caller: ${callerId}`);
91
+ }
92
+ // Both directions checked in one query: at most the two rows for this pair exist.
93
+ const blocks = await this.drizzle.db
94
+ .select({ blockerId: chatUserBlock.blockerId, blockedId: chatUserBlock.blockedId })
95
+ .from(chatUserBlock)
96
+ .where(and(isNull(chatUserBlock.removedAt), pairBlockCondition(callerId, targetUserId)));
97
+ if (blocks.some((b) => b.blockerId === targetUserId && b.blockedId === callerId)) {
98
+ // Target has blocked the caller - never disclosed, same error as "suspended".
99
+ throw new FriendRequestUnavailableError();
100
+ }
101
+ if (blocks.some((b) => b.blockerId === callerId && b.blockedId === targetUserId)) {
102
+ throw new BlockedBySelfError();
103
+ }
104
+ let inserted;
105
+ try {
106
+ const rows = await this.drizzle.db
107
+ .insert(friendship)
108
+ .values({ requesterId: callerId, addresseeId: targetUserId })
109
+ .returning();
110
+ inserted = rows[0];
111
+ }
112
+ catch (error) {
113
+ if (!isUniqueConstraintViolation(error)) {
114
+ throw error;
115
+ }
116
+ }
117
+ if (inserted) {
118
+ this.events.emit('social.friend_request.sent', {
119
+ friendshipId: inserted.id,
120
+ requesterId: inserted.requesterId,
121
+ addresseeId: inserted.addresseeId,
122
+ requesterDisplayName: callerPlayer.displayName,
123
+ });
124
+ return toFriendshipDto(inserted);
125
+ }
126
+ const accepted = await this.resolveExistingPair(callerId, targetUserId);
127
+ this.events.emit('social.friend_request.accepted', {
128
+ friendshipId: accepted.id,
129
+ requesterId: accepted.requesterId,
130
+ addresseeId: accepted.addresseeId,
131
+ accepterId: callerId,
132
+ accepterDisplayName: callerPlayer.displayName,
133
+ });
134
+ return toFriendshipDto(accepted);
135
+ }
136
+ /**
137
+ * Re-reads the row a unique-violation on `friendship_pair_key` proved already
138
+ * exists and resolves it: already accepted -> conflict, same-direction duplicate
139
+ * -> conflict, opposite-direction (mutual/simultaneous request) -> auto-accept.
140
+ * `FOR UPDATE` serializes this against a concurrent resolution of the same pair.
141
+ */
142
+ async resolveExistingPair(callerId, targetUserId) {
143
+ return this.drizzle.db.transaction(async (tx) => {
144
+ const rows = await tx
145
+ .select()
146
+ .from(friendship)
147
+ .where(pairCondition(callerId, targetUserId))
148
+ .for('update')
149
+ .limit(1);
150
+ const existing = rows[0];
151
+ if (!existing) {
152
+ // The row that caused the violation is gone (eg concurrently removed) -
153
+ // safe to surface as "no longer pending", the caller can retry.
154
+ throw new RequestAlreadyPendingError();
155
+ }
156
+ if (existing.refusedAt !== null) {
157
+ throw new FriendRequestRefusedError();
158
+ }
159
+ if (existing.acceptedAt !== null) {
160
+ throw new AlreadyFriendsError();
161
+ }
162
+ if (existing.requesterId === callerId) {
163
+ throw new RequestAlreadyPendingError();
164
+ }
165
+ // Mutual/simultaneous case: existing.requesterId === targetUserId - the
166
+ // pending row was already addressed TO the caller.
167
+ const updatedRows = await tx
168
+ .update(friendship)
169
+ .set({ acceptedAt: new Date(), refusedAt: null })
170
+ .where(eq(friendship.id, existing.id))
171
+ .returning();
172
+ const updated = updatedRows[0];
173
+ if (!updated) {
174
+ throw new RequestAlreadyPendingError();
175
+ }
176
+ return updated;
177
+ });
178
+ }
179
+ /**
180
+ * Batch relationship lookup for a set of target users, from the caller's point
181
+ * of view. Reads are batched (one query per table, `IN (...)` over the target
182
+ * ids) rather than N+1 per target.
183
+ */
184
+ async getRelationships(callerId, targetUserIds) {
185
+ const uniqueTargetIds = [...new Set(targetUserIds)];
186
+ const players = await this.drizzle.db
187
+ .select({ userId: player.userId, status: player.status })
188
+ .from(player)
189
+ .where(inArray(player.userId, uniqueTargetIds));
190
+ const playerByUserId = new Map(players.map((p) => [p.userId, p]));
191
+ const friendships = await this.drizzle.db
192
+ .select()
193
+ .from(friendship)
194
+ .where(or(and(eq(friendship.requesterId, callerId), inArray(friendship.addresseeId, uniqueTargetIds)), and(inArray(friendship.requesterId, uniqueTargetIds), eq(friendship.addresseeId, callerId))));
195
+ const friendshipByTargetId = new Map();
196
+ for (const row of friendships) {
197
+ const targetId = row.requesterId === callerId ? row.addresseeId : row.requesterId;
198
+ friendshipByTargetId.set(targetId, row);
199
+ }
200
+ const blocks = await this.drizzle.db
201
+ .select({ blockerId: chatUserBlock.blockerId, blockedId: chatUserBlock.blockedId })
202
+ .from(chatUserBlock)
203
+ .where(and(isNull(chatUserBlock.removedAt), or(and(eq(chatUserBlock.blockerId, callerId), inArray(chatUserBlock.blockedId, uniqueTargetIds)), and(inArray(chatUserBlock.blockerId, uniqueTargetIds), eq(chatUserBlock.blockedId, callerId)))));
204
+ const blockedByMe = new Set(blocks.filter((b) => b.blockerId === callerId).map((b) => b.blockedId));
205
+ const blockedByTarget = new Set(blocks.filter((b) => b.blockedId === callerId).map((b) => b.blockerId));
206
+ return targetUserIds.map((userId) => {
207
+ const targetPlayer = playerByUserId.get(userId);
208
+ if (!targetPlayer ||
209
+ targetPlayer.status === 'suspended' ||
210
+ targetPlayer.status === 'closed' ||
211
+ blockedByTarget.has(userId)) {
212
+ return { userId, status: 'unavailable', friendshipId: null, canSendRequest: false };
213
+ }
214
+ if (blockedByMe.has(userId)) {
215
+ return { userId, status: 'blocked_by_me', friendshipId: null, canSendRequest: false };
216
+ }
217
+ const existing = friendshipByTargetId.get(userId);
218
+ if (existing?.refusedAt !== null && existing?.refusedAt !== undefined) {
219
+ return { userId, status: 'refused', friendshipId: existing.id, canSendRequest: false };
220
+ }
221
+ if (existing?.acceptedAt !== null && existing?.acceptedAt !== undefined) {
222
+ return { userId, status: 'friends', friendshipId: existing.id, canSendRequest: false };
223
+ }
224
+ if (existing?.acceptedAt === null &&
225
+ existing?.refusedAt === null &&
226
+ existing?.requesterId === callerId) {
227
+ return {
228
+ userId,
229
+ status: 'pending_outgoing',
230
+ friendshipId: existing.id,
231
+ canSendRequest: false,
232
+ };
233
+ }
234
+ if (existing?.acceptedAt === null &&
235
+ existing?.refusedAt === null &&
236
+ existing?.requesterId === userId) {
237
+ return {
238
+ userId,
239
+ status: 'pending_incoming',
240
+ friendshipId: existing.id,
241
+ canSendRequest: false,
242
+ };
243
+ }
244
+ return { userId, status: 'none', friendshipId: null, canSendRequest: true };
245
+ });
246
+ }
247
+ }
248
+ function pairBlockCondition(callerId, targetUserId) {
249
+ return or(and(eq(chatUserBlock.blockerId, targetUserId), eq(chatUserBlock.blockedId, callerId)), and(eq(chatUserBlock.blockerId, callerId), eq(chatUserBlock.blockedId, targetUserId)));
250
+ }
251
+ //# sourceMappingURL=social.service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"social.service.js","sourceRoot":"","sources":["../../../../src/engagement/social/service/social.service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,aAAa,CAAC;AAC3D,OAAO,EAGL,iBAAiB,EACjB,iBAAiB,EACjB,iBAAiB,EACjB,YAAY,GACb,MAAM,sBAAsB,CAAC;AAE9B,OAAO,EAAE,aAAa,EAAE,MAAM,sCAAsC,CAAC;AACrE,OAAO,EAAE,MAAM,EAAE,MAAM,kCAAkC,CAAC;AAC1D,OAAO,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAKhD,2FAA2F;AAC3F,MAAM,CAAC,MAAM,sBAAsB,GAAG,iBAAiB,CACrD,wBAAwB,EACxB,GAAG,EAAE,CAAC,8CAA8C,CACrD,CAAC;AAEF,kFAAkF;AAClF,kFAAkF;AAClF,0DAA0D;AAC1D,MAAM,CAAC,MAAM,gCAAgC,GAAG,iBAAiB,CAAC,qBAAqB,CAAC,CAAC;AAEzF,kFAAkF;AAClF,iFAAiF;AACjF,gFAAgF;AAChF,6EAA6E;AAC7E,oBAAoB;AACpB,MAAM,CAAC,MAAM,6BAA6B,GAAG,iBAAiB,CAC5D,4BAA4B,EAC5B,sDAAsD,CACvD,CAAC;AAEF,6EAA6E;AAC7E,MAAM,CAAC,MAAM,kBAAkB,GAAG,iBAAiB,CACjD,iBAAiB,EACjB,mDAAmD,CACpD,CAAC;AACF,MAAM,CAAC,MAAM,mBAAmB,GAAG,iBAAiB,CAClD,iBAAiB,EACjB,0CAA0C,CAC3C,CAAC;AACF,MAAM,CAAC,MAAM,0BAA0B,GAAG,iBAAiB,CACzD,yBAAyB,EACzB,oDAAoD,CACrD,CAAC;AACF,MAAM,CAAC,MAAM,yBAAyB,GAAG,iBAAiB,CACxD,wBAAwB,EACxB,iCAAiC,CAClC,CAAC;AAEF,qEAAqE;AACrE,+EAA+E;AAC/E,wEAAwE;AACxE,+EAA+E;AAC/E,+EAA+E;AAC/E,2EAA2E;AAC3E,SAAS,WAAW,CAAC,CAAU;IAC7B,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC;QACxC,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,IAAI,MAAM,IAAI,CAAC,IAAI,OAAO,CAAC,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;QAC9C,OAAO,CAAC,CAAC,IAAI,CAAC;IAChB,CAAC;IACD,IAAI,OAAO,IAAI,CAAC,EAAE,CAAC;QACjB,OAAO,WAAW,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;IAC9B,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC;AAED,SAAS,2BAA2B,CAAC,CAAU;IAC7C,OAAO,WAAW,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC;AACpC,CAAC;AAED,SAAS,eAAe,CAAC,GAAkB;IACzC,OAAO,YAAY,CAAC,GAAG,EAAE;QACvB,UAAU,EAAE,CAAC,WAAW,EAAE,YAAY,EAAE,WAAW,CAAC;KACrD,CAAC,CAAC;AACL,CAAC;AAED,iFAAiF;AACjF,4DAA4D;AAC5D,SAAS,aAAa,CAAC,KAAW,EAAE,KAAW;IAC7C,OAAO,EAAE,CACP,GAAG,CAAC,EAAE,CAAC,UAAU,CAAC,WAAW,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,UAAU,CAAC,WAAW,EAAE,KAAK,CAAC,CAAC,EACzE,GAAG,CAAC,EAAE,CAAC,UAAU,CAAC,WAAW,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,UAAU,CAAC,WAAW,EAAE,KAAK,CAAC,CAAC,CAC1E,CAAC;AACJ,CAAC;AAED,MAAM,OAAO,aAAa;IAEL;IACA;IAFnB,YACmB,OAAuB,EACvB,MAAgB;QADhB,YAAO,GAAP,OAAO,CAAgB;QACvB,WAAM,GAAN,MAAM,CAAU;IAChC,CAAC;IAEJ;;;;;;;;OAQG;IACH,KAAK,CAAC,iBAAiB,CAAC,QAAc,EAAE,YAAkB;QACxD,IAAI,YAAY,KAAK,QAAQ,EAAE,CAAC;YAC9B,MAAM,IAAI,sBAAsB,EAAE,CAAC;QACrC,CAAC;QAED,8EAA8E;QAC9E,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,EAAE;aAClC,MAAM,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,EAAE,MAAM,CAAC,WAAW,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC;aACzF,IAAI,CAAC,MAAM,CAAC;aACZ,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,YAAY,CAAC,CAAC,CAAC,CAAC;QAC3D,MAAM,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,KAAK,YAAY,CAAC,CAAC;QACpE,MAAM,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,KAAK,QAAQ,CAAC,CAAC;QAEhE,IAAI,CAAC,YAAY,EAAE,CAAC;YAClB,MAAM,IAAI,gCAAgC,CAAC,YAAY,CAAC,CAAC;QAC3D,CAAC;QACD,IAAI,YAAY,CAAC,MAAM,KAAK,WAAW,IAAI,YAAY,CAAC,MAAM,KAAK,QAAQ,EAAE,CAAC;YAC5E,MAAM,IAAI,6BAA6B,EAAE,CAAC;QAC5C,CAAC;QACD,IAAI,CAAC,YAAY,EAAE,CAAC;YAClB,2EAA2E;YAC3E,6EAA6E;YAC7E,MAAM,IAAI,KAAK,CAAC,oDAAoD,QAAQ,EAAE,CAAC,CAAC;QAClF,CAAC;QAED,kFAAkF;QAClF,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,EAAE;aACjC,MAAM,CAAC,EAAE,SAAS,EAAE,aAAa,CAAC,SAAS,EAAE,SAAS,EAAE,aAAa,CAAC,SAAS,EAAE,CAAC;aAClF,IAAI,CAAC,aAAa,CAAC;aACnB,KAAK,CAAC,GAAG,CAAC,MAAM,CAAC,aAAa,CAAC,SAAS,CAAC,EAAE,kBAAkB,CAAC,QAAQ,EAAE,YAAY,CAAC,CAAC,CAAC,CAAC;QAC3F,IAAI,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,SAAS,KAAK,YAAY,IAAI,CAAC,CAAC,SAAS,KAAK,QAAQ,CAAC,EAAE,CAAC;YACjF,8EAA8E;YAC9E,MAAM,IAAI,6BAA6B,EAAE,CAAC;QAC5C,CAAC;QACD,IAAI,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,SAAS,KAAK,QAAQ,IAAI,CAAC,CAAC,SAAS,KAAK,YAAY,CAAC,EAAE,CAAC;YACjF,MAAM,IAAI,kBAAkB,EAAE,CAAC;QACjC,CAAC;QAED,IAAI,QAAmC,CAAC;QACxC,IAAI,CAAC;YACH,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,EAAE;iBAC/B,MAAM,CAAC,UAAU,CAAC;iBAClB,MAAM,CAAC,EAAE,WAAW,EAAE,QAAQ,EAAE,WAAW,EAAE,YAAY,EAAE,CAAC;iBAC5D,SAAS,EAAE,CAAC;YACf,QAAQ,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC;QACrB,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,IAAI,CAAC,2BAA2B,CAAC,KAAK,CAAC,EAAE,CAAC;gBACxC,MAAM,KAAK,CAAC;YACd,CAAC;QACH,CAAC;QAED,IAAI,QAAQ,EAAE,CAAC;YACb,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,4BAA4B,EAAE;gBAC7C,YAAY,EAAE,QAAQ,CAAC,EAAE;gBACzB,WAAW,EAAE,QAAQ,CAAC,WAAW;gBACjC,WAAW,EAAE,QAAQ,CAAC,WAAW;gBACjC,oBAAoB,EAAE,YAAY,CAAC,WAAW;aAC/C,CAAC,CAAC;YACH,OAAO,eAAe,CAAC,QAAQ,CAAC,CAAC;QACnC,CAAC;QAED,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,mBAAmB,CAAC,QAAQ,EAAE,YAAY,CAAC,CAAC;QACxE,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,gCAAgC,EAAE;YACjD,YAAY,EAAE,QAAQ,CAAC,EAAE;YACzB,WAAW,EAAE,QAAQ,CAAC,WAAW;YACjC,WAAW,EAAE,QAAQ,CAAC,WAAW;YACjC,UAAU,EAAE,QAAQ;YACpB,mBAAmB,EAAE,YAAY,CAAC,WAAW;SAC9C,CAAC,CAAC;QACH,OAAO,eAAe,CAAC,QAAQ,CAAC,CAAC;IACnC,CAAC;IAED;;;;;OAKG;IACK,KAAK,CAAC,mBAAmB,CAAC,QAAc,EAAE,YAAkB;QAClE,OAAO,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,WAAW,CAAC,KAAK,EAAE,EAAE,EAAE,EAAE;YAC9C,MAAM,IAAI,GAAG,MAAM,EAAE;iBAClB,MAAM,EAAE;iBACR,IAAI,CAAC,UAAU,CAAC;iBAChB,KAAK,CAAC,aAAa,CAAC,QAAQ,EAAE,YAAY,CAAC,CAAC;iBAC5C,GAAG,CAAC,QAAQ,CAAC;iBACb,KAAK,CAAC,CAAC,CAAC,CAAC;YACZ,MAAM,QAAQ,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC;YACzB,IAAI,CAAC,QAAQ,EAAE,CAAC;gBACd,wEAAwE;gBACxE,gEAAgE;gBAChE,MAAM,IAAI,0BAA0B,EAAE,CAAC;YACzC,CAAC;YACD,IAAI,QAAQ,CAAC,SAAS,KAAK,IAAI,EAAE,CAAC;gBAChC,MAAM,IAAI,yBAAyB,EAAE,CAAC;YACxC,CAAC;YACD,IAAI,QAAQ,CAAC,UAAU,KAAK,IAAI,EAAE,CAAC;gBACjC,MAAM,IAAI,mBAAmB,EAAE,CAAC;YAClC,CAAC;YACD,IAAI,QAAQ,CAAC,WAAW,KAAK,QAAQ,EAAE,CAAC;gBACtC,MAAM,IAAI,0BAA0B,EAAE,CAAC;YACzC,CAAC;YAED,wEAAwE;YACxE,mDAAmD;YACnD,MAAM,WAAW,GAAG,MAAM,EAAE;iBACzB,MAAM,CAAC,UAAU,CAAC;iBAClB,GAAG,CAAC,EAAE,UAAU,EAAE,IAAI,IAAI,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC;iBAChD,KAAK,CAAC,EAAE,CAAC,UAAU,CAAC,EAAE,EAAE,QAAQ,CAAC,EAAE,CAAC,CAAC;iBACrC,SAAS,EAAE,CAAC;YACf,MAAM,OAAO,GAAG,WAAW,CAAC,CAAC,CAAC,CAAC;YAC/B,IAAI,CAAC,OAAO,EAAE,CAAC;gBACb,MAAM,IAAI,0BAA0B,EAAE,CAAC;YACzC,CAAC;YACD,OAAO,OAAO,CAAC;QACjB,CAAC,CAAC,CAAC;IACL,CAAC;IAED;;;;OAIG;IACH,KAAK,CAAC,gBAAgB,CAAC,QAAc,EAAE,aAA8B;QACnE,MAAM,eAAe,GAAG,CAAC,GAAG,IAAI,GAAG,CAAC,aAAa,CAAC,CAAC,CAAC;QAEpD,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,EAAE;aAClC,MAAM,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC;aACxD,IAAI,CAAC,MAAM,CAAC;aACZ,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC,CAAC;QAClD,MAAM,cAAc,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;QAElE,MAAM,WAAW,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,EAAE;aACtC,MAAM,EAAE;aACR,IAAI,CAAC,UAAU,CAAC;aAChB,KAAK,CACJ,EAAE,CACA,GAAG,CACD,EAAE,CAAC,UAAU,CAAC,WAAW,EAAE,QAAQ,CAAC,EACpC,OAAO,CAAC,UAAU,CAAC,WAAW,EAAE,eAAe,CAAC,CACjD,EACD,GAAG,CACD,OAAO,CAAC,UAAU,CAAC,WAAW,EAAE,eAAe,CAAC,EAChD,EAAE,CAAC,UAAU,CAAC,WAAW,EAAE,QAAQ,CAAC,CACrC,CACF,CACF,CAAC;QACJ,MAAM,oBAAoB,GAAG,IAAI,GAAG,EAAyB,CAAC;QAC9D,KAAK,MAAM,GAAG,IAAI,WAAW,EAAE,CAAC;YAC9B,MAAM,QAAQ,GAAG,GAAG,CAAC,WAAW,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC,CAAC,GAAG,CAAC,WAAW,CAAC;YAClF,oBAAoB,CAAC,GAAG,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAC;QAC1C,CAAC;QAED,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,EAAE;aACjC,MAAM,CAAC,EAAE,SAAS,EAAE,aAAa,CAAC,SAAS,EAAE,SAAS,EAAE,aAAa,CAAC,SAAS,EAAE,CAAC;aAClF,IAAI,CAAC,aAAa,CAAC;aACnB,KAAK,CACJ,GAAG,CACD,MAAM,CAAC,aAAa,CAAC,SAAS,CAAC,EAC/B,EAAE,CACA,GAAG,CACD,EAAE,CAAC,aAAa,CAAC,SAAS,EAAE,QAAQ,CAAC,EACrC,OAAO,CAAC,aAAa,CAAC,SAAS,EAAE,eAAe,CAAC,CAClD,EACD,GAAG,CACD,OAAO,CAAC,aAAa,CAAC,SAAS,EAAE,eAAe,CAAC,EACjD,EAAE,CAAC,aAAa,CAAC,SAAS,EAAE,QAAQ,CAAC,CACtC,CACF,CACF,CACF,CAAC;QACJ,MAAM,WAAW,GAAG,IAAI,GAAG,CACzB,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,SAAS,KAAK,QAAQ,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CACvE,CAAC;QACF,MAAM,eAAe,GAAG,IAAI,GAAG,CAC7B,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,SAAS,KAAK,QAAQ,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CACvE,CAAC;QAEF,OAAO,aAAa,CAAC,GAAG,CAAC,CAAC,MAAM,EAAgB,EAAE;YAChD,MAAM,YAAY,GAAG,cAAc,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;YAChD,IACE,CAAC,YAAY;gBACb,YAAY,CAAC,MAAM,KAAK,WAAW;gBACnC,YAAY,CAAC,MAAM,KAAK,QAAQ;gBAChC,eAAe,CAAC,GAAG,CAAC,MAAM,CAAC,EAC3B,CAAC;gBACD,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa,EAAE,YAAY,EAAE,IAAI,EAAE,cAAc,EAAE,KAAK,EAAE,CAAC;YACtF,CAAC;YACD,IAAI,WAAW,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC;gBAC5B,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,eAAe,EAAE,YAAY,EAAE,IAAI,EAAE,cAAc,EAAE,KAAK,EAAE,CAAC;YACxF,CAAC;YAED,MAAM,QAAQ,GAAG,oBAAoB,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;YAClD,IAAI,QAAQ,EAAE,SAAS,KAAK,IAAI,IAAI,QAAQ,EAAE,SAAS,KAAK,SAAS,EAAE,CAAC;gBACtE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,YAAY,EAAE,QAAQ,CAAC,EAAE,EAAE,cAAc,EAAE,KAAK,EAAE,CAAC;YACzF,CAAC;YACD,IAAI,QAAQ,EAAE,UAAU,KAAK,IAAI,IAAI,QAAQ,EAAE,UAAU,KAAK,SAAS,EAAE,CAAC;gBACxE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,YAAY,EAAE,QAAQ,CAAC,EAAE,EAAE,cAAc,EAAE,KAAK,EAAE,CAAC;YACzF,CAAC;YACD,IACE,QAAQ,EAAE,UAAU,KAAK,IAAI;gBAC7B,QAAQ,EAAE,SAAS,KAAK,IAAI;gBAC5B,QAAQ,EAAE,WAAW,KAAK,QAAQ,EAClC,CAAC;gBACD,OAAO;oBACL,MAAM;oBACN,MAAM,EAAE,kBAAkB;oBAC1B,YAAY,EAAE,QAAQ,CAAC,EAAE;oBACzB,cAAc,EAAE,KAAK;iBACtB,CAAC;YACJ,CAAC;YACD,IACE,QAAQ,EAAE,UAAU,KAAK,IAAI;gBAC7B,QAAQ,EAAE,SAAS,KAAK,IAAI;gBAC5B,QAAQ,EAAE,WAAW,KAAK,MAAM,EAChC,CAAC;gBACD,OAAO;oBACL,MAAM;oBACN,MAAM,EAAE,kBAAkB;oBAC1B,YAAY,EAAE,QAAQ,CAAC,EAAE;oBACzB,cAAc,EAAE,KAAK;iBACtB,CAAC;YACJ,CAAC;YACD,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,YAAY,EAAE,IAAI,EAAE,cAAc,EAAE,IAAI,EAAE,CAAC;QAC9E,CAAC,CAAC,CAAC;IACL,CAAC;CACF;AAED,SAAS,kBAAkB,CAAC,QAAc,EAAE,YAAkB;IAC5D,OAAO,EAAE,CACP,GAAG,CAAC,EAAE,CAAC,aAAa,CAAC,SAAS,EAAE,YAAY,CAAC,EAAE,EAAE,CAAC,aAAa,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC,EACrF,GAAG,CAAC,EAAE,CAAC,aAAa,CAAC,SAAS,EAAE,QAAQ,CAAC,EAAE,EAAE,CAAC,aAAa,CAAC,SAAS,EAAE,YAAY,CAAC,CAAC,CACtF,CAAC;AACJ,CAAC"}
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest';
3
3
  import { sql, eq } from 'drizzle-orm';
4
- import { mock, NO_CLIENT_META, makeEventBus } from '../../testing/mock.js';
4
+ import { makeIdentityReader, mock, NO_CLIENT_META, makeEventBus } from '../../testing/mock.js';
5
5
  import { statement, findOneOrThrow, RedisCache, RedisRateLimiter, } from '@openora/core/server';
6
6
  import { createTestDb, createTestRedis } from '@openora/core/testing';
7
7
  import { migrate as migrateIam } from '@openora/core/iam/migrate';
@@ -12,6 +12,10 @@ import { IamService, DbAdminPermissionResolver, RoleNotFoundError, InvalidGrantE
12
12
  let db;
13
13
  let redis;
14
14
  const makeEmail = () => mock({ send: vi.fn().mockResolvedValue(undefined) });
15
+ const identityReader = makeIdentityReader();
16
+ function makeIamService(drizzle, events, email, sessionCommands, rateLimiter) {
17
+ return new IamService(drizzle, events, email, identityReader, sessionCommands, rateLimiter);
18
+ }
15
19
  // Bootstrap super-admin: no DB assignment row + user.role === 'admin' passes the static fallback.
16
20
  const ADMIN_CALLER = { userId: randomUUID(), role: 'admin', ...NO_CLIENT_META };
17
21
  const SUPPORT_CALLER = { userId: randomUUID(), role: 'support', ...NO_CLIENT_META };
@@ -55,7 +59,7 @@ beforeEach(async () => {
55
59
  });
56
60
  describe('IamService.listCatalog', () => {
57
61
  it('omits the admin module from the assignable catalog', () => {
58
- const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
62
+ const svc = makeIamService(db.drizzle, makeEventBus(), makeEmail());
59
63
  const catalog = svc.listCatalog();
60
64
  expect(catalog.modules.some((m) => m.resource === 'admin')).toBe(false);
61
65
  expect(catalog.modules.some((m) => m.resource === 'player')).toBe(true);
@@ -143,7 +147,7 @@ describe('IamService.setRolePermissions', () => {
143
147
  it('rejects a non-super-admin caller and audits the denial', async () => {
144
148
  const role = await seedRole();
145
149
  const events = makeEventBus();
146
- const svc = new IamService(db.drizzle, events, makeEmail());
150
+ const svc = makeIamService(db.drizzle, events, makeEmail());
147
151
  await expect(svc.setRolePermissions({
148
152
  roleId: role.id,
149
153
  grants: [{ resource: 'player', level: 'read' }],
@@ -157,7 +161,7 @@ describe('IamService.setRolePermissions', () => {
157
161
  }));
158
162
  });
159
163
  it('throws RoleNotFoundError when the role does not exist', async () => {
160
- const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
164
+ const svc = makeIamService(db.drizzle, makeEventBus(), makeEmail());
161
165
  await expect(svc.setRolePermissions({
162
166
  roleId: randomUUID(),
163
167
  grants: [{ resource: 'player', level: 'read' }],
@@ -166,7 +170,7 @@ describe('IamService.setRolePermissions', () => {
166
170
  });
167
171
  it('throws InvalidGrantError for an unknown module', async () => {
168
172
  const role = await seedRole();
169
- const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
173
+ const svc = makeIamService(db.drizzle, makeEventBus(), makeEmail());
170
174
  await expect(svc.setRolePermissions({
171
175
  roleId: role.id,
172
176
  grants: [{ resource: 'nonexistent', level: 'read' }],
@@ -175,7 +179,7 @@ describe('IamService.setRolePermissions', () => {
175
179
  });
176
180
  it('rejects a grant targeting the admin module (super-admin-only, A6)', async () => {
177
181
  const role = await seedRole();
178
- const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
182
+ const svc = makeIamService(db.drizzle, makeEventBus(), makeEmail());
179
183
  await expect(svc.setRolePermissions({
180
184
  roleId: role.id,
181
185
  grants: [{ resource: 'admin', level: 'read_write' }],
@@ -184,7 +188,7 @@ describe('IamService.setRolePermissions', () => {
184
188
  });
185
189
  it('blocks editing a super-admin role (always full)', async () => {
186
190
  const role = await seedRole({ isSuperAdmin: true, isSystem: true });
187
- const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
191
+ const svc = makeIamService(db.drizzle, makeEventBus(), makeEmail());
188
192
  await expect(svc.setRolePermissions({
189
193
  roleId: role.id,
190
194
  grants: [{ resource: 'player', level: 'read' }],
@@ -193,7 +197,7 @@ describe('IamService.setRolePermissions', () => {
193
197
  });
194
198
  it('a super caller may grant any level and the rows are persisted', async () => {
195
199
  const role = await seedRole();
196
- const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
200
+ const svc = makeIamService(db.drizzle, makeEventBus(), makeEmail());
197
201
  const result = await svc.setRolePermissions({
198
202
  roleId: role.id,
199
203
  grants: [{ resource: 'withdrawal', level: 'read_write' }],
@@ -210,7 +214,7 @@ describe('IamService.setRolePermissions', () => {
210
214
  it('emits iam.role.permissions.changed with actorId', async () => {
211
215
  const role = await seedRole();
212
216
  const events = makeEventBus();
213
- const svc = new IamService(db.drizzle, events, makeEmail());
217
+ const svc = makeIamService(db.drizzle, events, makeEmail());
214
218
  await svc.setRolePermissions({
215
219
  roleId: role.id,
216
220
  grants: [{ resource: 'player', level: 'read' }],
@@ -222,13 +226,13 @@ describe('IamService.setRolePermissions', () => {
222
226
  describe('IamService.updateRole', () => {
223
227
  it('blocks renaming the super-admin role', async () => {
224
228
  const role = await seedRole({ isSuperAdmin: true, isSystem: true });
225
- const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
229
+ const svc = makeIamService(db.drizzle, makeEventBus(), makeEmail());
226
230
  await expect(svc.updateRole({ roleId: role.id, name: 'Renamed', caller: ADMIN_CALLER })).rejects.toBeInstanceOf(ProtectedRoleError);
227
231
  });
228
232
  it('allows renaming a predefined (isSystem, non-super) role', async () => {
229
233
  const role = await seedRole({ isSystem: true, isSuperAdmin: false });
230
234
  const events = makeEventBus();
231
- const svc = new IamService(db.drizzle, events, makeEmail());
235
+ const svc = makeIamService(db.drizzle, events, makeEmail());
232
236
  const result = await svc.updateRole({ roleId: role.id, name: 'Renamed', caller: ADMIN_CALLER });
233
237
  expect(result.name).toBe('Renamed');
234
238
  expect(events.emit).toHaveBeenCalledWith('iam.role.updated', expect.objectContaining({ roleId: role.id, actorId: ADMIN_CALLER.userId }));
@@ -239,18 +243,18 @@ describe('IamService.updateRole', () => {
239
243
  describe('IamService.deleteRole', () => {
240
244
  it('blocks deleting a system role', async () => {
241
245
  const role = await seedRole({ isSystem: true });
242
- const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
246
+ const svc = makeIamService(db.drizzle, makeEventBus(), makeEmail());
243
247
  await expect(svc.deleteRole({ roleId: role.id, caller: ADMIN_CALLER })).rejects.toBeInstanceOf(ProtectedRoleError);
244
248
  });
245
249
  it('blocks deleting a super-admin role', async () => {
246
250
  const role = await seedRole({ isSuperAdmin: true });
247
- const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
251
+ const svc = makeIamService(db.drizzle, makeEventBus(), makeEmail());
248
252
  await expect(svc.deleteRole({ roleId: role.id, caller: ADMIN_CALLER })).rejects.toBeInstanceOf(ProtectedRoleError);
249
253
  });
250
254
  it('deletes a custom role with no assignments and emits only iam.role.deleted', async () => {
251
255
  const role = await seedRole();
252
256
  const events = makeEventBus();
253
- const svc = new IamService(db.drizzle, events, makeEmail());
257
+ const svc = makeIamService(db.drizzle, events, makeEmail());
254
258
  const result = await svc.deleteRole({ roleId: role.id, caller: ADMIN_CALLER });
255
259
  expect(result).toEqual({ success: true });
256
260
  expect(events.emit).toHaveBeenCalledWith('iam.role.deleted', expect.objectContaining({ roleId: role.id, actorId: ADMIN_CALLER.userId }));
@@ -264,7 +268,7 @@ describe('IamService.deleteRole', () => {
264
268
  await seedAssignment(userA, role.id);
265
269
  await seedAssignment(userB, role.id);
266
270
  const events = makeEventBus();
267
- const svc = new IamService(db.drizzle, events, makeEmail());
271
+ const svc = makeIamService(db.drizzle, events, makeEmail());
268
272
  const result = await svc.deleteRole({ roleId: role.id, caller: ADMIN_CALLER });
269
273
  expect(result).toEqual({ success: true });
270
274
  expect(events.emit).toHaveBeenCalledWith('iam.role.revoked', {
@@ -292,7 +296,7 @@ describe('IamService.assignRole', () => {
292
296
  const target = await seedUser('admin');
293
297
  await seedAssignment(target.id, role.id);
294
298
  const events = makeEventBus();
295
- const svc = new IamService(db.drizzle, events, makeEmail());
299
+ const svc = makeIamService(db.drizzle, events, makeEmail());
296
300
  const result = await svc.assignRole({
297
301
  userId: target.id,
298
302
  roleId: role.id,
@@ -307,18 +311,18 @@ describe('IamService.assignRole', () => {
307
311
  it('rejects assigning a role to a non-admin (player) account', async () => {
308
312
  const role = await seedRole();
309
313
  const player = await seedUser('player');
310
- const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
314
+ const svc = makeIamService(db.drizzle, makeEventBus(), makeEmail());
311
315
  await expect(svc.assignRole({ userId: player.id, roleId: role.id, caller: ADMIN_CALLER })).rejects.toThrow(NotAnAdminUserError);
312
316
  });
313
317
  it('rejects assigning a role to an unknown user', async () => {
314
318
  const role = await seedRole();
315
- const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
319
+ const svc = makeIamService(db.drizzle, makeEventBus(), makeEmail());
316
320
  await expect(svc.assignRole({ userId: randomUUID(), roleId: role.id, caller: ADMIN_CALLER })).rejects.toThrow(AdminUserNotFoundError);
317
321
  });
318
322
  it('the unique index keeps two concurrent assigns from double-inserting', async () => {
319
323
  const role = await seedRole();
320
324
  const target = await seedUser('admin');
321
- const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
325
+ const svc = makeIamService(db.drizzle, makeEventBus(), makeEmail());
322
326
  await Promise.allSettled([
323
327
  svc.assignRole({ userId: target.id, roleId: role.id, caller: ADMIN_CALLER }),
324
328
  svc.assignRole({ userId: target.id, roleId: role.id, caller: ADMIN_CALLER }),
@@ -335,7 +339,7 @@ describe('IamService.unassignRole', () => {
335
339
  const superRole = await seedRole({ isSuperAdmin: true, isSystem: true });
336
340
  const target = randomUUID();
337
341
  await seedAssignment(target, superRole.id);
338
- const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
342
+ const svc = makeIamService(db.drizzle, makeEventBus(), makeEmail());
339
343
  await expect(svc.unassignRole({ userId: target, roleId: superRole.id, caller: ADMIN_CALLER })).rejects.toBeInstanceOf(LastSuperAdminError);
340
344
  // The holder survives the rejected unassign.
341
345
  expect(await db.drizzle.db.select().from(adminRoleAssignment)).toHaveLength(1);
@@ -343,7 +347,7 @@ describe('IamService.unassignRole', () => {
343
347
  it('returns success but emits no revoked event when nothing was deleted', async () => {
344
348
  const role = await seedRole();
345
349
  const events = makeEventBus();
346
- const svc = new IamService(db.drizzle, events, makeEmail());
350
+ const svc = makeIamService(db.drizzle, events, makeEmail());
347
351
  const result = await svc.unassignRole({
348
352
  userId: randomUUID(),
349
353
  roleId: role.id,
@@ -357,7 +361,7 @@ describe('IamService.unassignRole', () => {
357
361
  const target = randomUUID();
358
362
  await seedAssignment(target, role.id);
359
363
  const events = makeEventBus();
360
- const svc = new IamService(db.drizzle, events, makeEmail());
364
+ const svc = makeIamService(db.drizzle, events, makeEmail());
361
365
  await svc.unassignRole({ userId: target, roleId: role.id, caller: ADMIN_CALLER });
362
366
  expect(events.emit).toHaveBeenCalledWith('iam.role.revoked', expect.objectContaining({ roleId: role.id, userId: target, actorId: ADMIN_CALLER.userId }));
363
367
  expect(await db.drizzle.db.select().from(adminRoleAssignment)).toHaveLength(0);
@@ -368,7 +372,7 @@ describe('IamService.unassignRole', () => {
368
372
  const userB = randomUUID();
369
373
  await seedAssignment(userA, superRole.id);
370
374
  await seedAssignment(userB, superRole.id);
371
- const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
375
+ const svc = makeIamService(db.drizzle, makeEventBus(), makeEmail());
372
376
  const results = await Promise.allSettled([
373
377
  svc.unassignRole({ userId: userA, roleId: superRole.id, caller: ADMIN_CALLER }),
374
378
  svc.unassignRole({ userId: userB, roleId: superRole.id, caller: ADMIN_CALLER }),
@@ -387,7 +391,7 @@ describe('IamService.previewEffectivePermissions', () => {
387
391
  await seedPermission(roleA.id, 'player', 'read');
388
392
  await seedPermission(roleA.id, 'withdrawal', 'read');
389
393
  await seedPermission(roleB.id, 'player', 'read_write');
390
- const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
394
+ const svc = makeIamService(db.drizzle, makeEventBus(), makeEmail());
391
395
  const result = await svc.previewEffectivePermissions({ roleIds: [roleA.id, roleB.id] });
392
396
  expect(result.permissions).toContainEqual({ resource: 'player', level: 'read_write' });
393
397
  expect(result.permissions).toContainEqual({ resource: 'withdrawal', level: 'read' });
@@ -395,14 +399,14 @@ describe('IamService.previewEffectivePermissions', () => {
395
399
  });
396
400
  it('super-admin role in the set yields all modules read_write', async () => {
397
401
  const superRole = await seedRole({ isSuperAdmin: true });
398
- const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
402
+ const svc = makeIamService(db.drizzle, makeEventBus(), makeEmail());
399
403
  const result = await svc.previewEffectivePermissions({ roleIds: [superRole.id] });
400
404
  expect(result.permissions).toHaveLength(Object.keys(statement).length);
401
405
  expect(result.permissions.every((p) => p.level === 'read_write')).toBe(true);
402
406
  });
403
407
  it('falls back to static role permissions when the user has no dynamic role assignments', async () => {
404
408
  const u = await seedUser('support');
405
- const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
409
+ const svc = makeIamService(db.drizzle, makeEventBus(), makeEmail());
406
410
  const result = await svc.previewEffectivePermissions({ userId: u.id });
407
411
  expect(result.permissions).not.toHaveLength(0);
408
412
  expect(result.permissions).toContainEqual({ resource: 'player', level: 'read' });
@@ -424,7 +428,7 @@ describe('IamService.acceptInvitation', () => {
424
428
  it('accepts once and emits exactly one event; a replay conflicts and emits nothing more', async () => {
425
429
  const token = await seedInvitation();
426
430
  const events = makeEventBus();
427
- const svc = new IamService(db.drizzle, events, makeEmail());
431
+ const svc = makeIamService(db.drizzle, events, makeEmail());
428
432
  const first = await svc.acceptInvitation(token);
429
433
  expect(first).toEqual({ success: true, email: 'who@admin.com' });
430
434
  await expect(svc.acceptInvitation(token)).rejects.toBeInstanceOf(InvitationConflictError);
@@ -433,7 +437,7 @@ describe('IamService.acceptInvitation', () => {
433
437
  it('two concurrent accepts: exactly one succeeds, one event (atomic conditional UPDATE)', async () => {
434
438
  const token = await seedInvitation();
435
439
  const events = makeEventBus();
436
- const svc = new IamService(db.drizzle, events, makeEmail());
440
+ const svc = makeIamService(db.drizzle, events, makeEmail());
437
441
  const results = await Promise.allSettled([
438
442
  svc.acceptInvitation(token),
439
443
  svc.acceptInvitation(token),
@@ -450,7 +454,7 @@ describe('IamService.inviteAdmin', () => {
450
454
  it('creates a pending invitation and calls SEND_EMAIL when caller is super-admin', async () => {
451
455
  const role = await seedRole();
452
456
  const email = makeEmail();
453
- const svc = new IamService(db.drizzle, makeEventBus(), email);
457
+ const svc = makeIamService(db.drizzle, makeEventBus(), email);
454
458
  const result = await svc.inviteAdmin({
455
459
  email: 'new@admin.com',
456
460
  roleId: role.id,
@@ -467,7 +471,7 @@ describe('IamService.inviteAdmin', () => {
467
471
  it('rejects a non-super-admin caller', async () => {
468
472
  const role = await seedRole();
469
473
  const email = makeEmail();
470
- const svc = new IamService(db.drizzle, makeEventBus(), email);
474
+ const svc = makeIamService(db.drizzle, makeEventBus(), email);
471
475
  await expect(svc.inviteAdmin({ email: 'new@admin.com', roleId: role.id, caller: SUPPORT_CALLER })).rejects.toBeInstanceOf(NotSuperAdminError);
472
476
  expect(email.send).not.toHaveBeenCalled();
473
477
  });
@@ -475,7 +479,7 @@ describe('IamService.inviteAdmin', () => {
475
479
  describe('IamService paginated lists', () => {
476
480
  it('listRoles returns { items, total, page, limit }', async () => {
477
481
  const role = await seedRole();
478
- const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
482
+ const svc = makeIamService(db.drizzle, makeEventBus(), makeEmail());
479
483
  const result = await svc.listRoles({ page: 1, limit: 10 });
480
484
  expect(result.total).toBe(1);
481
485
  expect(result.page).toBe(1);
@@ -492,7 +496,7 @@ describe('IamService paginated lists', () => {
492
496
  status: 'pending',
493
497
  expiresAt: new Date(Date.now() + 60_000),
494
498
  });
495
- const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
499
+ const svc = makeIamService(db.drizzle, makeEventBus(), makeEmail());
496
500
  const result = await svc.listInvitations({ page: 1, limit: 20 });
497
501
  expect(result.total).toBe(1);
498
502
  expect(result.items).toHaveLength(1);
@@ -501,7 +505,7 @@ describe('IamService paginated lists', () => {
501
505
  it('listAssignments returns the paginated wrapper with joined role fields', async () => {
502
506
  const role = await seedRole({ name: 'Ops' });
503
507
  await seedAssignment(randomUUID(), role.id);
504
- const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
508
+ const svc = makeIamService(db.drizzle, makeEventBus(), makeEmail());
505
509
  const result = await svc.listAssignments({ page: 1, limit: 20 });
506
510
  expect(result.total).toBe(1);
507
511
  expect(result.items).toHaveLength(1);
@@ -511,7 +515,7 @@ describe('IamService paginated lists', () => {
511
515
  describe('IamService.reportAccessDenied', () => {
512
516
  it('records and audits a genuine denial (caller lacks the resource/level)', async () => {
513
517
  const events = makeEventBus();
514
- const svc = new IamService(db.drizzle, events, makeEmail());
518
+ const svc = makeIamService(db.drizzle, events, makeEmail());
515
519
  const result = await svc.reportAccessDenied({
516
520
  resource: 'withdrawal',
517
521
  level: 'read',
@@ -527,7 +531,7 @@ describe('IamService.reportAccessDenied', () => {
527
531
  });
528
532
  it('does not record or emit when the caller genuinely holds the permission', async () => {
529
533
  const events = makeEventBus();
530
- const svc = new IamService(db.drizzle, events, makeEmail());
534
+ const svc = makeIamService(db.drizzle, events, makeEmail());
531
535
  const result = await svc.reportAccessDenied({
532
536
  resource: 'player',
533
537
  level: 'read_write',
@@ -539,7 +543,7 @@ describe('IamService.reportAccessDenied', () => {
539
543
  it('with a rate limiter wired, a repeat report within the window is throttled and not recorded again', async () => {
540
544
  const events = makeEventBus();
541
545
  const limiter = new RedisRateLimiter(redis.client);
542
- const svc = new IamService(db.drizzle, events, makeEmail(), undefined, limiter);
546
+ const svc = makeIamService(db.drizzle, events, makeEmail(), undefined, limiter);
543
547
  const first = await svc.reportAccessDenied({
544
548
  resource: 'withdrawal',
545
549
  level: 'read',
@@ -558,7 +562,7 @@ describe('IamService.reportAccessDenied', () => {
558
562
  });
559
563
  describe('IamService.forceLogout', () => {
560
564
  it('rejects a non-super-admin caller', async () => {
561
- const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
565
+ const svc = makeIamService(db.drizzle, makeEventBus(), makeEmail());
562
566
  await expect(svc.forceLogout({ userId: randomUUID(), caller: SUPPORT_CALLER })).rejects.toBeInstanceOf(NotSuperAdminError);
563
567
  });
564
568
  it('allows a super-admin caller to delete sessions for a user via SessionCommands', async () => {
@@ -566,7 +570,7 @@ describe('IamService.forceLogout', () => {
566
570
  const sessionCommands = mock({
567
571
  revokeAll: vi.fn().mockResolvedValue({ success: true }),
568
572
  });
569
- const svc = new IamService(db.drizzle, events, makeEmail(), sessionCommands);
573
+ const svc = makeIamService(db.drizzle, events, makeEmail(), sessionCommands);
570
574
  const targetUserId = randomUUID();
571
575
  const result = await svc.forceLogout({ userId: targetUserId, caller: ADMIN_CALLER });
572
576
  expect(result).toEqual({ success: true });