@openora/core 0.4.1-canary.60 → 0.4.1-canary.63

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 (217) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/admin-console/plugin.d.ts +2 -0
  3. package/dist/admin-console/plugin.d.ts.map +1 -1
  4. package/dist/analytics/plugin.d.ts +2 -0
  5. package/dist/analytics/plugin.d.ts.map +1 -1
  6. package/dist/audit/plugin.d.ts +2 -0
  7. package/dist/audit/plugin.d.ts.map +1 -1
  8. package/dist/audit/plugin.js +13 -0
  9. package/dist/audit/plugin.js.map +1 -1
  10. package/dist/casino/gaming/plugin.d.ts +2 -0
  11. package/dist/casino/gaming/plugin.d.ts.map +1 -1
  12. package/dist/casino/lobby/plugin.d.ts +2 -0
  13. package/dist/casino/lobby/plugin.d.ts.map +1 -1
  14. package/dist/cms/plugin.d.ts +2 -0
  15. package/dist/cms/plugin.d.ts.map +1 -1
  16. package/dist/compliance/plugin.d.ts +2 -0
  17. package/dist/compliance/plugin.d.ts.map +1 -1
  18. package/dist/contracts/adapters/audit.d.ts +1 -1
  19. package/dist/contracts/adapters/audit.d.ts.map +1 -1
  20. package/dist/contracts/adapters/audit.js.map +1 -1
  21. package/dist/contracts/adapters/chat-system-writer.d.ts +7 -1
  22. package/dist/contracts/adapters/chat-system-writer.d.ts.map +1 -1
  23. package/dist/contracts/adapters/chat-system-writer.js.map +1 -1
  24. package/dist/contracts/adapters/gift-commands.d.ts +58 -0
  25. package/dist/contracts/adapters/gift-commands.d.ts.map +1 -0
  26. package/dist/contracts/adapters/gift-commands.js +11 -0
  27. package/dist/contracts/adapters/gift-commands.js.map +1 -0
  28. package/dist/contracts/adapters/index.d.ts +5 -1
  29. package/dist/contracts/adapters/index.d.ts.map +1 -1
  30. package/dist/contracts/adapters/index.js +2 -0
  31. package/dist/contracts/adapters/index.js.map +1 -1
  32. package/dist/contracts/adapters/rain-commands.d.ts +27 -0
  33. package/dist/contracts/adapters/rain-commands.d.ts.map +1 -0
  34. package/dist/contracts/adapters/rain-commands.js +12 -0
  35. package/dist/contracts/adapters/rain-commands.js.map +1 -0
  36. package/dist/contracts/schemas/chat-command-metadata.d.ts +26 -0
  37. package/dist/contracts/schemas/chat-command-metadata.d.ts.map +1 -1
  38. package/dist/contracts/schemas/chat-command-metadata.js +25 -0
  39. package/dist/contracts/schemas/chat-command-metadata.js.map +1 -1
  40. package/dist/contracts/schemas/chat-command.d.ts +126 -0
  41. package/dist/contracts/schemas/chat-command.d.ts.map +1 -1
  42. package/dist/contracts/schemas/chat-command.js +19 -1
  43. package/dist/contracts/schemas/chat-command.js.map +1 -1
  44. package/dist/contracts/schemas/events.d.ts +15 -3
  45. package/dist/contracts/schemas/events.d.ts.map +1 -1
  46. package/dist/contracts/schemas/events.js +17 -6
  47. package/dist/contracts/schemas/events.js.map +1 -1
  48. package/dist/contracts/schemas/identity.d.ts +2 -1
  49. package/dist/contracts/schemas/identity.d.ts.map +1 -1
  50. package/dist/contracts/schemas/identity.js +5 -1
  51. package/dist/contracts/schemas/identity.js.map +1 -1
  52. package/dist/engagement/chat/__tests__/chat.service.int.test.js +211 -8
  53. package/dist/engagement/chat/__tests__/chat.service.int.test.js.map +1 -1
  54. package/dist/engagement/chat/contract/index.d.ts +719 -40
  55. package/dist/engagement/chat/contract/index.d.ts.map +1 -1
  56. package/dist/engagement/chat/contract/index.js +51 -5
  57. package/dist/engagement/chat/contract/index.js.map +1 -1
  58. package/dist/engagement/chat/drizzle/migrations/0004_panoramic_robin_chapel.sql +6 -0
  59. package/dist/engagement/chat/drizzle/migrations/meta/0004_snapshot.json +666 -0
  60. package/dist/engagement/chat/drizzle/migrations/meta/_journal.json +7 -0
  61. package/dist/engagement/chat/plugin.d.ts +2 -0
  62. package/dist/engagement/chat/plugin.d.ts.map +1 -1
  63. package/dist/engagement/chat/router/index.d.ts +608 -34
  64. package/dist/engagement/chat/router/index.d.ts.map +1 -1
  65. package/dist/engagement/chat/router/index.js +10 -2
  66. package/dist/engagement/chat/router/index.js.map +1 -1
  67. package/dist/engagement/chat/schema/index.d.ts +72 -0
  68. package/dist/engagement/chat/schema/index.d.ts.map +1 -1
  69. package/dist/engagement/chat/schema/index.js +15 -3
  70. package/dist/engagement/chat/schema/index.js.map +1 -1
  71. package/dist/engagement/chat/service/chat.service.d.ts +421 -34
  72. package/dist/engagement/chat/service/chat.service.d.ts.map +1 -1
  73. package/dist/engagement/chat/service/chat.service.js +155 -40
  74. package/dist/engagement/chat/service/chat.service.js.map +1 -1
  75. package/dist/engagement/chat-commands/contract/index.d.ts +181 -73
  76. package/dist/engagement/chat-commands/contract/index.d.ts.map +1 -1
  77. package/dist/engagement/chat-commands/contract/index.js +50 -72
  78. package/dist/engagement/chat-commands/contract/index.js.map +1 -1
  79. package/dist/engagement/chat-commands/plugin.d.ts +3 -1
  80. package/dist/engagement/chat-commands/plugin.d.ts.map +1 -1
  81. package/dist/engagement/chat-commands/plugin.js +4 -4
  82. package/dist/engagement/chat-commands/plugin.js.map +1 -1
  83. package/dist/engagement/chat-commands/router/index.d.ts +185 -83
  84. package/dist/engagement/chat-commands/router/index.d.ts.map +1 -1
  85. package/dist/engagement/chat-commands/router/index.js +35 -25
  86. package/dist/engagement/chat-commands/router/index.js.map +1 -1
  87. package/dist/engagement/chat-commands/seed/index.d.ts.map +1 -1
  88. package/dist/engagement/chat-commands/seed/index.js +12 -0
  89. package/dist/engagement/chat-commands/seed/index.js.map +1 -1
  90. package/dist/engagement/chat-commands/service/__tests__/chat-commands.service.test.js +205 -733
  91. package/dist/engagement/chat-commands/service/__tests__/chat-commands.service.test.js.map +1 -1
  92. package/dist/engagement/chat-commands/service/chat-commands.service.d.ts +44 -68
  93. package/dist/engagement/chat-commands/service/chat-commands.service.d.ts.map +1 -1
  94. package/dist/engagement/chat-commands/service/chat-commands.service.js +143 -571
  95. package/dist/engagement/chat-commands/service/chat-commands.service.js.map +1 -1
  96. package/dist/engagement/contracts.d.ts +1 -0
  97. package/dist/engagement/contracts.d.ts.map +1 -1
  98. package/dist/engagement/contracts.js +1 -0
  99. package/dist/engagement/contracts.js.map +1 -1
  100. package/dist/engagement/index.d.ts +1 -0
  101. package/dist/engagement/index.d.ts.map +1 -1
  102. package/dist/engagement/index.js +1 -0
  103. package/dist/engagement/index.js.map +1 -1
  104. package/dist/engagement/notifications/plugin.d.ts +2 -0
  105. package/dist/engagement/notifications/plugin.d.ts.map +1 -1
  106. package/dist/engagement/server.d.ts +1 -0
  107. package/dist/engagement/server.d.ts.map +1 -1
  108. package/dist/engagement/server.js +1 -0
  109. package/dist/engagement/server.js.map +1 -1
  110. package/dist/engagement/social-transfers/contract/index.d.ts +85 -0
  111. package/dist/engagement/social-transfers/contract/index.d.ts.map +1 -0
  112. package/dist/engagement/social-transfers/contract/index.js +21 -0
  113. package/dist/engagement/social-transfers/contract/index.js.map +1 -0
  114. package/dist/engagement/social-transfers/drizzle/migrations/0000_clammy_rattler.sql +46 -0
  115. package/dist/engagement/social-transfers/drizzle/migrations/meta/0000_snapshot.json +287 -0
  116. package/dist/engagement/social-transfers/drizzle/migrations/meta/_journal.json +13 -0
  117. package/dist/engagement/social-transfers/drizzle.config.d.ts +4 -0
  118. package/dist/engagement/social-transfers/drizzle.config.d.ts.map +1 -0
  119. package/dist/engagement/social-transfers/drizzle.config.js +21 -0
  120. package/dist/engagement/social-transfers/drizzle.config.js.map +1 -0
  121. package/dist/engagement/social-transfers/index.d.ts +3 -0
  122. package/dist/engagement/social-transfers/index.d.ts.map +1 -0
  123. package/dist/engagement/social-transfers/index.js +6 -0
  124. package/dist/engagement/social-transfers/index.js.map +1 -0
  125. package/dist/engagement/social-transfers/migrate.d.ts +5 -0
  126. package/dist/engagement/social-transfers/migrate.d.ts.map +1 -0
  127. package/dist/engagement/social-transfers/migrate.js +17 -0
  128. package/dist/engagement/social-transfers/migrate.js.map +1 -0
  129. package/dist/engagement/social-transfers/plugin.d.ts +111 -0
  130. package/dist/engagement/social-transfers/plugin.d.ts.map +1 -0
  131. package/dist/engagement/social-transfers/plugin.js +24 -0
  132. package/dist/engagement/social-transfers/plugin.js.map +1 -0
  133. package/dist/engagement/social-transfers/router/index.d.ts +77 -0
  134. package/dist/engagement/social-transfers/router/index.d.ts.map +1 -0
  135. package/dist/engagement/social-transfers/router/index.js +25 -0
  136. package/dist/engagement/social-transfers/router/index.js.map +1 -0
  137. package/dist/engagement/social-transfers/schema/index.d.ts +609 -0
  138. package/dist/engagement/social-transfers/schema/index.d.ts.map +1 -0
  139. package/dist/engagement/social-transfers/schema/index.js +61 -0
  140. package/dist/engagement/social-transfers/schema/index.js.map +1 -0
  141. package/dist/engagement/social-transfers/service/__tests__/social-transfers.service.test.d.ts +2 -0
  142. package/dist/engagement/social-transfers/service/__tests__/social-transfers.service.test.d.ts.map +1 -0
  143. package/dist/engagement/social-transfers/service/__tests__/social-transfers.service.test.js +757 -0
  144. package/dist/engagement/social-transfers/service/__tests__/social-transfers.service.test.js.map +1 -0
  145. package/dist/engagement/social-transfers/service/social-transfers.service.d.ts +75 -0
  146. package/dist/engagement/social-transfers/service/social-transfers.service.d.ts.map +1 -0
  147. package/dist/engagement/social-transfers/service/social-transfers.service.js +789 -0
  148. package/dist/engagement/social-transfers/service/social-transfers.service.js.map +1 -0
  149. package/dist/iam/plugin.d.ts +2 -0
  150. package/dist/iam/plugin.d.ts.map +1 -1
  151. package/dist/iam/service/iam.service.d.ts +1 -1
  152. package/dist/pam/identity/__tests__/identity.service.int.test.js +47 -2
  153. package/dist/pam/identity/__tests__/identity.service.int.test.js.map +1 -1
  154. package/dist/pam/identity/__tests__/phone-login.service.int.test.js +23 -2
  155. package/dist/pam/identity/__tests__/phone-login.service.int.test.js.map +1 -1
  156. package/dist/pam/identity/plugin.d.ts +2 -0
  157. package/dist/pam/identity/plugin.d.ts.map +1 -1
  158. package/dist/pam/identity/service/identity.service.d.ts.map +1 -1
  159. package/dist/pam/identity/service/identity.service.js +29 -1
  160. package/dist/pam/identity/service/identity.service.js.map +1 -1
  161. package/dist/pam/identity/service/phone-login.service.d.ts.map +1 -1
  162. package/dist/pam/identity/service/phone-login.service.js +23 -0
  163. package/dist/pam/identity/service/phone-login.service.js.map +1 -1
  164. package/dist/pam/player-management/__tests__/player.router.int.test.js +27 -4
  165. package/dist/pam/player-management/__tests__/player.router.int.test.js.map +1 -1
  166. package/dist/pam/player-management/__tests__/player.service.int.test.js +159 -6
  167. package/dist/pam/player-management/__tests__/player.service.int.test.js.map +1 -1
  168. package/dist/pam/player-management/contract/index.d.ts +39 -0
  169. package/dist/pam/player-management/contract/index.d.ts.map +1 -1
  170. package/dist/pam/player-management/contract/index.js +33 -1
  171. package/dist/pam/player-management/contract/index.js.map +1 -1
  172. package/dist/pam/player-management/plugin.d.ts +3 -1
  173. package/dist/pam/player-management/plugin.d.ts.map +1 -1
  174. package/dist/pam/player-management/plugin.js +3 -3
  175. package/dist/pam/player-management/plugin.js.map +1 -1
  176. package/dist/pam/player-management/router/index.d.ts +21 -0
  177. package/dist/pam/player-management/router/index.d.ts.map +1 -1
  178. package/dist/pam/player-management/router/index.js +12 -2
  179. package/dist/pam/player-management/router/index.js.map +1 -1
  180. package/dist/pam/player-management/service/player.service.d.ts +10 -3
  181. package/dist/pam/player-management/service/player.service.d.ts.map +1 -1
  182. package/dist/pam/player-management/service/player.service.js +66 -3
  183. package/dist/pam/player-management/service/player.service.js.map +1 -1
  184. package/dist/pam/player-note/__tests__/player-note.router.int.test.d.ts +2 -0
  185. package/dist/pam/player-note/__tests__/player-note.router.int.test.d.ts.map +1 -0
  186. package/dist/pam/player-note/__tests__/player-note.router.int.test.js +52 -0
  187. package/dist/pam/player-note/__tests__/player-note.router.int.test.js.map +1 -0
  188. package/dist/pam/player-note/plugin.d.ts +49 -46
  189. package/dist/pam/player-note/plugin.d.ts.map +1 -1
  190. package/dist/pam/player-note/plugin.js +3 -1
  191. package/dist/pam/player-note/plugin.js.map +1 -1
  192. package/dist/pam/player-note/router/index.d.ts +2 -1
  193. package/dist/pam/player-note/router/index.d.ts.map +1 -1
  194. package/dist/pam/player-note/router/index.js +13 -2
  195. package/dist/pam/player-note/router/index.js.map +1 -1
  196. package/dist/pam/profile/plugin.d.ts +2 -0
  197. package/dist/pam/profile/plugin.d.ts.map +1 -1
  198. package/dist/pam/tag/plugin.d.ts +2 -0
  199. package/dist/pam/tag/plugin.d.ts.map +1 -1
  200. package/dist/server/auth/permissions.d.ts +24 -0
  201. package/dist/server/auth/permissions.d.ts.map +1 -1
  202. package/dist/server/auth/permissions.js +4 -0
  203. package/dist/server/auth/permissions.js.map +1 -1
  204. package/dist/server/runtime/core-token-catalog.d.ts +2 -0
  205. package/dist/server/runtime/core-token-catalog.d.ts.map +1 -1
  206. package/dist/server/runtime/core-token-catalog.js +3 -1
  207. package/dist/server/runtime/core-token-catalog.js.map +1 -1
  208. package/dist/wallet/__tests__/wallet.service.int.test.js +7 -0
  209. package/dist/wallet/__tests__/wallet.service.int.test.js.map +1 -1
  210. package/dist/wallet/plugin.d.ts +2 -0
  211. package/dist/wallet/plugin.d.ts.map +1 -1
  212. package/dist/wallet/router/index.js +1 -1
  213. package/dist/wallet/router/index.js.map +1 -1
  214. package/dist/wallet/service/wallet.service.d.ts.map +1 -1
  215. package/dist/wallet/service/wallet.service.js +16 -0
  216. package/dist/wallet/service/wallet.service.js.map +1 -1
  217. package/package.json +21 -1
@@ -0,0 +1,789 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { and, eq, isNull, sql } from 'drizzle-orm';
3
+ import { createLogger, makeNotFoundError, makeConflictError, mapConcurrent, moneyToNumber, findOneOrThrow, createDomainError, } from '@openora/core/server';
4
+ import { chatChannel } from '@openora/core/contracts';
5
+ import { chatCommandConfig } from '@openora/core/engagement/schema/chat-commands';
6
+ import { playerGift, playerDonate, playerRain, playerRainReceiver } from '../schema/index.js';
7
+ const logger = createLogger('social-transfers');
8
+ function publishChatEvent(transport, roomId, event) {
9
+ void Promise.resolve()
10
+ .then(() => transport.publish(chatChannel(roomId), event))
11
+ .catch((err) => {
12
+ logger.error({ err, roomId }, 'chat realtime publish failed');
13
+ });
14
+ }
15
+ export const CommandDisabledError = makeNotFoundError('ChatCommand');
16
+ export const NoOnlineUsersError = makeConflictError('NoOnlineUsers', 'No other users are online in this room');
17
+ export const InsufficientBalanceError = makeConflictError('InsufficientBalance', 'Not enough balance');
18
+ export const ExceedsLimitError = makeConflictError('ExceedsLimit', 'Amount exceeds the command limit');
19
+ export const BelowMinimumError = makeConflictError('BelowMinimum', 'Amount is below the minimum for this command');
20
+ export const RainCreditError = makeConflictError('RainCreditError', 'A recipient wallet is unavailable; rain aborted');
21
+ export class ChatPlayerNotFoundError extends Error {
22
+ playerId;
23
+ constructor(playerId) {
24
+ super(`ChatPlayer not found: ${playerId}`);
25
+ this.playerId = playerId;
26
+ this.name = 'ChatPlayerNotFoundError';
27
+ }
28
+ }
29
+ export const GiftNotFoundError = makeNotFoundError('ChatGift');
30
+ export const GiftAlreadyClaimedError = makeConflictError('GiftAlreadyClaimed', 'This gift has already been claimed');
31
+ export const GiftSelfClaimError = makeConflictError('GiftSelfClaim', 'You cannot claim your own gift');
32
+ export const GiftCreditError = makeConflictError('GiftCreditError', 'Recipient wallet is unavailable; gift claim aborted');
33
+ export const DonateSelfError = makeConflictError('DonateSelf', 'You cannot donate to yourself');
34
+ export const TooManyRecipientsError = makeConflictError('TooManyRecipients', 'Amount too small: you need at least $1 per recipient');
35
+ export const ChatCommandIdempotencyKeyReuseError = makeConflictError('ChatCommandIdempotencyKeyReuse', 'This idempotency key was already used with different request parameters');
36
+ export const ConcurrentCommandReplayError = makeConflictError('ConcurrentCommandReplay', 'This request is already being processed - please retry');
37
+ export const ChatRoomNotMemberError = createDomainError('ChatRoomNotMemberError', (roomId) => `You are not a member of room: ${roomId}`);
38
+ // Internal-only: createDomainError instances don't expose their constructor args as
39
+ // properties, so this carries the gift's roomId across the doClaimGift -> toClaimGiftResult
40
+ // boundary - without it, the ClaimGiftResult's room_not_member reason has no roomId, and
41
+ // chat-commands (which only has giftId in scope) would build a ChatRoomNotMemberError with
42
+ // the wrong id. Never crosses the GIFT_COMMANDS port boundary itself.
43
+ class GiftRoomAccessError extends Error {
44
+ roomId;
45
+ constructor(roomId) {
46
+ super('room_not_member');
47
+ this.roomId = roomId;
48
+ this.name = 'GiftRoomAccessError';
49
+ }
50
+ }
51
+ const COMMAND_IDEMPOTENCY_TTL_MS = 5 * 60 * 1000;
52
+ // The replay guard must match on the COMPLETE request, not just the amount - a reused
53
+ // key with a different room, recipient count, or donate target is a distinct request,
54
+ // not a replay of the original. `idempotencyKey` itself is excluded so the fingerprint
55
+ // is stable for the row it guards. The cache-key namespace stays `chat-command:*` even
56
+ // though this logic now lives here - see AGENTS.md.
57
+ export function fingerprintCommand(input) {
58
+ const canonical = input.type === 'gift'
59
+ ? { type: input.type, amount: input.amount, roomId: input.roomId }
60
+ : input.type === 'rain'
61
+ ? {
62
+ type: input.type,
63
+ amount: input.amount,
64
+ recipientCount: input.recipientCount,
65
+ roomId: input.roomId,
66
+ }
67
+ : {
68
+ type: input.type,
69
+ amount: input.amount,
70
+ targetUsername: input.targetUsername,
71
+ roomId: input.roomId,
72
+ };
73
+ return createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
74
+ }
75
+ function shuffleArray(arr) {
76
+ const result = [...arr];
77
+ for (let i = result.length - 1; i > 0; i--) {
78
+ const j = Math.floor(Math.random() * (i + 1));
79
+ const a = result.at(i);
80
+ const b = result.at(j);
81
+ if (a !== undefined && b !== undefined) {
82
+ result[i] = b;
83
+ result[j] = a;
84
+ }
85
+ }
86
+ return result;
87
+ }
88
+ // Translates a thrown domain error into the GIFT_COMMANDS port's discriminated
89
+ // result so chat-commands never needs to import this module's error classes
90
+ // (module boundary) - only genuinely unexpected errors still throw across the
91
+ // port. `.name`-based fallback covers an error surfacing from CHAT_ROOM_ACCESS
92
+ // (a different module's class), mirroring the sender-side room-access guard.
93
+ function toSendGiftResult(error) {
94
+ if (error instanceof CommandDisabledError) {
95
+ return { ok: false, reason: 'disabled' };
96
+ }
97
+ if (error instanceof InsufficientBalanceError) {
98
+ return { ok: false, reason: 'insufficient_balance' };
99
+ }
100
+ if (error instanceof ExceedsLimitError) {
101
+ return { ok: false, reason: 'exceeds_limit' };
102
+ }
103
+ if (error instanceof BelowMinimumError) {
104
+ return { ok: false, reason: 'below_minimum' };
105
+ }
106
+ if (error instanceof ChatPlayerNotFoundError) {
107
+ return { ok: false, reason: 'player_not_found', playerId: error.playerId };
108
+ }
109
+ if (error instanceof ChatCommandIdempotencyKeyReuseError) {
110
+ return { ok: false, reason: 'idempotency_key_reuse' };
111
+ }
112
+ if (error instanceof ConcurrentCommandReplayError) {
113
+ return { ok: false, reason: 'concurrent_replay' };
114
+ }
115
+ if (error instanceof ChatRoomNotMemberError ||
116
+ (error instanceof Error && error.name === 'ChatRoomNotMemberError')) {
117
+ return { ok: false, reason: 'room_not_member' };
118
+ }
119
+ throw error;
120
+ }
121
+ function toClaimGiftResult(error) {
122
+ if (error instanceof GiftNotFoundError) {
123
+ return { ok: false, reason: 'gift_not_found' };
124
+ }
125
+ if (error instanceof GiftAlreadyClaimedError) {
126
+ return { ok: false, reason: 'already_claimed' };
127
+ }
128
+ if (error instanceof GiftSelfClaimError) {
129
+ return { ok: false, reason: 'self_claim' };
130
+ }
131
+ if (error instanceof GiftRoomAccessError) {
132
+ return { ok: false, reason: 'room_not_member', roomId: error.roomId };
133
+ }
134
+ if (error instanceof GiftCreditError) {
135
+ return { ok: false, reason: 'gift_credit_failed' };
136
+ }
137
+ throw error;
138
+ }
139
+ function toGetGiftResult(error) {
140
+ if (error instanceof GiftNotFoundError) {
141
+ return { ok: false, reason: 'gift_not_found' };
142
+ }
143
+ if (error instanceof GiftRoomAccessError) {
144
+ return { ok: false, reason: 'room_not_member', roomId: error.roomId };
145
+ }
146
+ throw error;
147
+ }
148
+ function toSendRainResult(error) {
149
+ if (error instanceof CommandDisabledError) {
150
+ return { ok: false, reason: 'disabled' };
151
+ }
152
+ if (error instanceof InsufficientBalanceError) {
153
+ return { ok: false, reason: 'insufficient_balance' };
154
+ }
155
+ if (error instanceof ExceedsLimitError) {
156
+ return { ok: false, reason: 'exceeds_limit' };
157
+ }
158
+ if (error instanceof BelowMinimumError) {
159
+ return { ok: false, reason: 'below_minimum' };
160
+ }
161
+ if (error instanceof ChatPlayerNotFoundError) {
162
+ return { ok: false, reason: 'player_not_found', playerId: error.playerId };
163
+ }
164
+ if (error instanceof NoOnlineUsersError) {
165
+ return { ok: false, reason: 'no_online_users' };
166
+ }
167
+ if (error instanceof TooManyRecipientsError) {
168
+ return { ok: false, reason: 'too_many_recipients' };
169
+ }
170
+ if (error instanceof RainCreditError) {
171
+ return { ok: false, reason: 'rain_credit_failed' };
172
+ }
173
+ if (error instanceof ChatCommandIdempotencyKeyReuseError) {
174
+ return { ok: false, reason: 'idempotency_key_reuse' };
175
+ }
176
+ if (error instanceof ConcurrentCommandReplayError) {
177
+ return { ok: false, reason: 'concurrent_replay' };
178
+ }
179
+ if (error instanceof ChatRoomNotMemberError ||
180
+ (error instanceof Error && error.name === 'ChatRoomNotMemberError')) {
181
+ return { ok: false, reason: 'room_not_member' };
182
+ }
183
+ throw error;
184
+ }
185
+ export class SocialTransfersService {
186
+ drizzle;
187
+ systemWriter;
188
+ wallet;
189
+ directory;
190
+ audit;
191
+ transport;
192
+ events;
193
+ roomAccess;
194
+ cache;
195
+ idempotencyTtlMs;
196
+ constructor(drizzle, systemWriter, wallet, directory, audit, transport, events, roomAccess, cache, idempotencyTtlMs = COMMAND_IDEMPOTENCY_TTL_MS) {
197
+ this.drizzle = drizzle;
198
+ this.systemWriter = systemWriter;
199
+ this.wallet = wallet;
200
+ this.directory = directory;
201
+ this.audit = audit;
202
+ this.transport = transport;
203
+ this.events = events;
204
+ this.roomAccess = roomAccess;
205
+ this.cache = cache;
206
+ this.idempotencyTtlMs = idempotencyTtlMs;
207
+ }
208
+ // GIFT_COMMANDS port impl, consumed by chat-commands via DI - never throws,
209
+ // every failure the caller must distinguish comes back as a typed `reason`.
210
+ async sendGift(input, actorId) {
211
+ try {
212
+ const message = await this.doSendGift(input, actorId);
213
+ return { ok: true, message };
214
+ }
215
+ catch (error) {
216
+ return toSendGiftResult(error);
217
+ }
218
+ }
219
+ async claimGift(giftId, claimerId) {
220
+ try {
221
+ return await this.doClaimGift(giftId, claimerId);
222
+ }
223
+ catch (error) {
224
+ return toClaimGiftResult(error);
225
+ }
226
+ }
227
+ async getGift(giftId, viewerId) {
228
+ try {
229
+ const gift = await this.doGetGift(giftId, viewerId);
230
+ return { ok: true, gift };
231
+ }
232
+ catch (error) {
233
+ return toGetGiftResult(error);
234
+ }
235
+ }
236
+ // RAIN_COMMANDS port impl, consumed by chat-commands via DI - never throws.
237
+ // `input.onlineUserIds` is resolved by the caller (chat-commands owns
238
+ // presence via its own dependency on `chat`); this module never queries
239
+ // online status itself.
240
+ async sendRain(input, actorId) {
241
+ try {
242
+ const message = await this.doSendRain(input, actorId);
243
+ return { ok: true, message };
244
+ }
245
+ catch (error) {
246
+ return toSendRainResult(error);
247
+ }
248
+ }
249
+ async sendDonate(input, actorId) {
250
+ return this.doSendDonate(input, actorId);
251
+ }
252
+ async loadCommandConfig(key) {
253
+ const [row] = await this.drizzle.db
254
+ .select()
255
+ .from(chatCommandConfig)
256
+ .where(eq(chatCommandConfig.key, key))
257
+ .limit(1);
258
+ if (!row || !row.enabled) {
259
+ throw new CommandDisabledError(key);
260
+ }
261
+ return row.config ?? null;
262
+ }
263
+ async verifyRoomAccessIfNeeded(roomId, actorId) {
264
+ if (!roomId) {
265
+ return;
266
+ }
267
+ try {
268
+ await this.roomAccess.verifyRoomAccess(roomId, actorId);
269
+ }
270
+ catch (error) {
271
+ if (error instanceof Error && error.name === 'ChatRoomNotMemberError') {
272
+ throw new ChatRoomNotMemberError(roomId);
273
+ }
274
+ throw error;
275
+ }
276
+ }
277
+ async findCommandReplay(commandType, actorId, idempotencyKey, fingerprint) {
278
+ const record = await this.cache.get(this.idempotencyCacheKey(commandType, actorId, idempotencyKey));
279
+ if (!record) {
280
+ return null;
281
+ }
282
+ if (record.fingerprint !== fingerprint) {
283
+ throw new ChatCommandIdempotencyKeyReuseError();
284
+ }
285
+ if (!record.result) {
286
+ throw new ConcurrentCommandReplayError();
287
+ }
288
+ return record.result;
289
+ }
290
+ idempotencyCacheKey(commandType, actorId, idempotencyKey) {
291
+ return `chat-command:idempotency:${actorId}:${commandType}:${idempotencyKey}`;
292
+ }
293
+ async reserveCommandIdempotency(commandType, actorId, idempotencyKey, fingerprint) {
294
+ const reserved = await this.cache.setIfAbsent(this.idempotencyCacheKey(commandType, actorId, idempotencyKey), { fingerprint, result: null }, { ttlMs: this.idempotencyTtlMs });
295
+ if (!reserved) {
296
+ throw new ConcurrentCommandReplayError();
297
+ }
298
+ }
299
+ async completeCommandIdempotency(commandType, actorId, idempotencyKey, fingerprint, result) {
300
+ try {
301
+ await this.cache.set(this.idempotencyCacheKey(commandType, actorId, idempotencyKey), { fingerprint, result }, { ttlMs: this.idempotencyTtlMs });
302
+ }
303
+ catch (err) {
304
+ logger.error({ err, commandType, actorId, idempotencyKey }, 'idempotency result cache failed');
305
+ }
306
+ }
307
+ async runWithCommandReservation(commandType, actorId, idempotencyKey, work) {
308
+ try {
309
+ return await work();
310
+ }
311
+ catch (error) {
312
+ try {
313
+ await this.cache.delete(this.idempotencyCacheKey(commandType, actorId, idempotencyKey));
314
+ }
315
+ catch (cleanupError) {
316
+ logger.error({ err: cleanupError, commandType, actorId, idempotencyKey }, 'idempotency reservation cleanup failed');
317
+ }
318
+ throw error;
319
+ }
320
+ }
321
+ // Exact, case-insensitive username resolution for callers holding a complete, already-known
322
+ // username (never a partial search term) - `/donate`. Distinct from findPlayerIds' capped
323
+ // fuzzy substring search, which can silently drop the real match once more than 20 unrelated
324
+ // accounts substring-collide with a short/common username.
325
+ async resolveExactPlayer(username) {
326
+ const summary = await this.directory.getPlayerByUsername(username);
327
+ if (!summary) {
328
+ throw new ChatPlayerNotFoundError(username);
329
+ }
330
+ return summary;
331
+ }
332
+ async doSendGift(input, actorId) {
333
+ await this.verifyRoomAccessIfNeeded(input.roomId, actorId);
334
+ const config = await this.loadCommandConfig('gift');
335
+ if (config?.maxAmount !== undefined &&
336
+ moneyToNumber(input.amount) > moneyToNumber(config.maxAmount)) {
337
+ throw new ExceedsLimitError();
338
+ }
339
+ if (config?.minAmount !== undefined &&
340
+ moneyToNumber(input.amount) < moneyToNumber(config.minAmount)) {
341
+ throw new BelowMinimumError();
342
+ }
343
+ const senderSummaries = await this.directory.lookupPlayers([actorId]);
344
+ const senderSummary = senderSummaries.find((s) => s.userId === actorId);
345
+ if (!senderSummary) {
346
+ throw new ChatPlayerNotFoundError(actorId);
347
+ }
348
+ const senderUsername = senderSummary.username;
349
+ // Sender resolved BEFORE reserving (mirrors doSendDonate): runWithCommandReservation
350
+ // only releases the reservation on a failure inside its own callback, so anything
351
+ // between reserveCommandIdempotency and that callback would otherwise leak a stuck
352
+ // reservation for the full TTL on a transient directory error.
353
+ const fingerprint = fingerprintCommand({ type: 'gift', ...input });
354
+ const replay = await this.findCommandReplay('gift', actorId, input.idempotencyKey, fingerprint);
355
+ if (replay) {
356
+ return replay;
357
+ }
358
+ await this.reserveCommandIdempotency('gift', actorId, input.idempotencyKey, fingerprint);
359
+ const { msg, giftId, currency } = await this.runWithCommandReservation('gift', actorId, input.idempotencyKey, () => this.drizzle.db.transaction(async (tx) => {
360
+ const debit = await this.wallet.debit(tx, {
361
+ userId: actorId,
362
+ amount: input.amount,
363
+ type: 'gift',
364
+ });
365
+ if (!debit.ok) {
366
+ throw new InsufficientBalanceError();
367
+ }
368
+ const [giftRow] = await tx
369
+ .insert(playerGift)
370
+ .values({
371
+ senderId: actorId,
372
+ senderUsername,
373
+ amount: input.amount,
374
+ currency: debit.currency,
375
+ roomId: input.roomId,
376
+ // messageId is back-filled below, in the same transaction, once the
377
+ // system message exists (same insert-then-update pattern as before).
378
+ messageId: '00000000-0000-0000-0000-000000000000',
379
+ })
380
+ .returning();
381
+ if (!giftRow) {
382
+ throw new InsufficientBalanceError();
383
+ }
384
+ const message = await this.systemWriter.postSystemMessage({
385
+ roomId: input.roomId,
386
+ actorId,
387
+ tx,
388
+ metadata: {
389
+ command: 'gift',
390
+ giftId: giftRow.id,
391
+ senderId: actorId,
392
+ senderUsername,
393
+ amount: input.amount,
394
+ currency: debit.currency,
395
+ status: 'available',
396
+ claimedBy: null,
397
+ claimedByUsername: null,
398
+ claimedAt: null,
399
+ },
400
+ });
401
+ await tx
402
+ .update(playerGift)
403
+ .set({ messageId: message.id })
404
+ .where(eq(playerGift.id, giftRow.id));
405
+ await this.audit.recordInTransaction(tx, {
406
+ actorId,
407
+ actorType: 'player',
408
+ action: 'chat.gift',
409
+ resourceType: 'player_gift',
410
+ resourceId: giftRow.id,
411
+ before: null,
412
+ after: { amount: input.amount, roomId: input.roomId },
413
+ });
414
+ return { msg: message, giftId: giftRow.id, currency: debit.currency };
415
+ }));
416
+ await this.completeCommandIdempotency('gift', actorId, input.idempotencyKey, fingerprint, msg);
417
+ // The caller now owns the commit boundary: postSystemMessage was passed `tx` above so
418
+ // it did not auto-publish - publish only now that this transaction has committed.
419
+ publishChatEvent(this.transport, input.roomId, msg);
420
+ void this.events.emit('chat.gift.sent', {
421
+ giftId,
422
+ senderId: actorId,
423
+ senderUsername,
424
+ amount: input.amount,
425
+ currency,
426
+ roomId: input.roomId,
427
+ messageId: msg.id,
428
+ });
429
+ return msg;
430
+ }
431
+ async doClaimGift(giftId, claimerId) {
432
+ const claimerSummaries = await this.directory.lookupPlayers([claimerId]);
433
+ const claimerSummary = claimerSummaries.find((s) => s.userId === claimerId);
434
+ if (!claimerSummary) {
435
+ throw new GiftNotFoundError(claimerId);
436
+ }
437
+ const claimerUsername = claimerSummary.username;
438
+ const { claimed, currency, roomId, senderId, message } = await this.drizzle.db.transaction(async (tx) => {
439
+ // FOR UPDATE serializes concurrent claims against the same gift row under READ COMMITTED.
440
+ const giftRow = findOneOrThrow(await tx.select().from(playerGift).where(eq(playerGift.id, giftId)).for('update'), new GiftNotFoundError(giftId));
441
+ try {
442
+ await this.verifyRoomAccessIfNeeded(giftRow.roomId, claimerId);
443
+ }
444
+ catch (error) {
445
+ if (error instanceof Error && error.name === 'ChatRoomNotMemberError') {
446
+ throw new GiftRoomAccessError(giftRow.roomId);
447
+ }
448
+ throw error;
449
+ }
450
+ if (giftRow.senderId === claimerId) {
451
+ throw new GiftSelfClaimError();
452
+ }
453
+ if (giftRow.claimedBy) {
454
+ throw new GiftAlreadyClaimedError();
455
+ }
456
+ const claimedAt = new Date();
457
+ // Guarded conditional update: WHERE claimed_by IS NULL stays as defense-in-depth
458
+ // alongside the row lock above (same pattern as wallet's guarded debit).
459
+ const results = await tx
460
+ .update(playerGift)
461
+ .set({ claimedBy: claimerId, claimedByUsername: claimerUsername, claimedAt })
462
+ .where(and(eq(playerGift.id, giftId), isNull(playerGift.claimedBy)))
463
+ .returning();
464
+ const updated = findOneOrThrow(results, new GiftAlreadyClaimedError());
465
+ const credit = await this.wallet.credit(tx, {
466
+ userId: claimerId,
467
+ amount: updated.amount,
468
+ currency: updated.currency,
469
+ type: 'gift',
470
+ });
471
+ if (!credit.ok) {
472
+ throw new GiftCreditError();
473
+ }
474
+ await this.audit.recordInTransaction(tx, {
475
+ actorId: claimerId,
476
+ actorType: 'player',
477
+ action: 'chat.gift.claimed',
478
+ resourceType: 'player_gift',
479
+ resourceId: giftId,
480
+ before: null,
481
+ after: { claimedBy: claimerId, amount: updated.amount },
482
+ });
483
+ const updatedMessage = await this.systemWriter.updateSystemMessage({
484
+ messageId: giftRow.messageId,
485
+ tx,
486
+ metadata: {
487
+ command: 'gift',
488
+ giftId: giftRow.id,
489
+ senderId: giftRow.senderId,
490
+ senderUsername: giftRow.senderUsername,
491
+ amount: giftRow.amount,
492
+ currency: updated.currency,
493
+ status: 'claimed',
494
+ claimedBy: claimerId,
495
+ claimedByUsername: claimerUsername,
496
+ claimedAt: claimedAt.toISOString(),
497
+ },
498
+ });
499
+ return {
500
+ claimed: updated,
501
+ currency: updated.currency,
502
+ roomId: updated.roomId,
503
+ senderId: giftRow.senderId,
504
+ message: updatedMessage,
505
+ };
506
+ });
507
+ const claimedAtIso = claimed.claimedAt?.toISOString() ?? new Date().toISOString();
508
+ publishChatEvent(this.transport, roomId, message);
509
+ void this.events.emit('chat.gift.claimed', {
510
+ giftId,
511
+ claimerId,
512
+ claimerUsername,
513
+ senderId,
514
+ amount: claimed.amount,
515
+ currency,
516
+ roomId,
517
+ });
518
+ return {
519
+ ok: true,
520
+ claimedBy: claimerId,
521
+ claimedByUsername: claimerUsername,
522
+ claimedAt: claimedAtIso,
523
+ };
524
+ }
525
+ async doGetGift(giftId, viewerId) {
526
+ const rows = await this.drizzle.db.select().from(playerGift).where(eq(playerGift.id, giftId));
527
+ const giftRow = findOneOrThrow(rows, new GiftNotFoundError(giftId));
528
+ // Same room-membership boundary as doClaimGift: a viewer must be a member
529
+ // of the gift's room to poll its status. No FOR UPDATE - read-only, no
530
+ // mutation, no need to serialize against concurrent claims.
531
+ try {
532
+ await this.verifyRoomAccessIfNeeded(giftRow.roomId, viewerId);
533
+ }
534
+ catch (error) {
535
+ if (error instanceof Error && error.name === 'ChatRoomNotMemberError') {
536
+ throw new GiftRoomAccessError(giftRow.roomId);
537
+ }
538
+ throw error;
539
+ }
540
+ return {
541
+ id: giftRow.id,
542
+ senderId: giftRow.senderId,
543
+ senderUsername: giftRow.senderUsername,
544
+ amount: giftRow.amount,
545
+ currency: giftRow.currency,
546
+ claimedBy: giftRow.claimedBy,
547
+ claimedByUsername: giftRow.claimedByUsername,
548
+ claimedAt: giftRow.claimedAt ? giftRow.claimedAt.toISOString() : null,
549
+ createdAt: giftRow.createdAt.toISOString(),
550
+ };
551
+ }
552
+ async doSendRain(input, actorId) {
553
+ await this.verifyRoomAccessIfNeeded(input.roomId, actorId);
554
+ const config = await this.loadCommandConfig('rain');
555
+ if (config?.maxAmount !== undefined &&
556
+ moneyToNumber(input.amount) > moneyToNumber(config.maxAmount)) {
557
+ throw new ExceedsLimitError();
558
+ }
559
+ if (config?.minAmount !== undefined &&
560
+ moneyToNumber(input.amount) < moneyToNumber(config.minAmount)) {
561
+ throw new BelowMinimumError();
562
+ }
563
+ const configMax = config?.maxRecipients ?? 50;
564
+ if (input.recipientCount > configMax) {
565
+ throw new ExceedsLimitError();
566
+ }
567
+ const amountUnits = Math.floor(moneyToNumber(input.amount));
568
+ if (input.recipientCount > amountUnits) {
569
+ throw new TooManyRecipientsError();
570
+ }
571
+ const senderSummaries = await this.directory.lookupPlayers([actorId]);
572
+ const sender = senderSummaries.find((s) => s.userId === actorId);
573
+ if (!sender) {
574
+ throw new ChatPlayerNotFoundError(actorId);
575
+ }
576
+ const fingerprint = fingerprintCommand({ type: 'rain', ...input });
577
+ const replay = await this.findCommandReplay('rain', actorId, input.idempotencyKey, fingerprint);
578
+ if (replay) {
579
+ return replay;
580
+ }
581
+ await this.reserveCommandIdempotency('rain', actorId, input.idempotencyKey, fingerprint);
582
+ // Recipients resolved INSIDE runWithCommandReservation's callback (unlike doSendGift's
583
+ // pre-reservation directory lookup): runWithCommandReservation only releases the reservation
584
+ // on a failure inside its own callback, so throwing NoOnlineUsersError here - after
585
+ // reserveCommandIdempotency but before the callback - would otherwise leak a stuck reservation
586
+ // for the full TTL. It must also stay after findCommandReplay/reserveCommandIdempotency so a
587
+ // replay with a now-empty onlineUserIds list (the caller retried after everyone left) still
588
+ // returns the original stored result instead of a spurious NoOnlineUsersError.
589
+ const { msg, currency, totalDistributed, recipientIds } = await this.runWithCommandReservation('rain', actorId, input.idempotencyKey, async () => {
590
+ const recipients = shuffleArray(input.onlineUserIds.filter((id) => id !== actorId)).slice(0, input.recipientCount);
591
+ if (recipients.length === 0) {
592
+ throw new NoOnlineUsersError();
593
+ }
594
+ const recipientSummaries = await this.directory.lookupPlayers(recipients);
595
+ const recipientDetails = recipients.map((recipientId) => {
596
+ const summary = recipientSummaries.find((s) => s.userId === recipientId);
597
+ if (!summary) {
598
+ throw new ChatPlayerNotFoundError(recipientId);
599
+ }
600
+ return { userId: summary.userId, username: summary.username };
601
+ });
602
+ return this.drizzle.db.transaction(async (tx) => {
603
+ const splitResult = await tx.execute(sql `SELECT
604
+ (floor(floor(${input.amount}::numeric) / ${recipients.length}))::text AS per_recipient,
605
+ (floor(floor(${input.amount}::numeric) / ${recipients.length}) * ${recipients.length})::text AS total_distributed`);
606
+ const { per_recipient: perRecipient, total_distributed: totalDistributed } = splitResult
607
+ .rows[0];
608
+ const debit = await this.wallet.debit(tx, {
609
+ userId: actorId,
610
+ amount: totalDistributed,
611
+ type: 'rain',
612
+ });
613
+ if (!debit.ok) {
614
+ throw new InsufficientBalanceError();
615
+ }
616
+ const credits = await mapConcurrent(recipients, 10, (userId) => this.wallet.credit(tx, {
617
+ userId,
618
+ amount: perRecipient,
619
+ currency: debit.currency,
620
+ type: 'rain',
621
+ }));
622
+ if (credits.some((c) => !c.ok)) {
623
+ throw new RainCreditError();
624
+ }
625
+ const [rainRow] = await tx
626
+ .insert(playerRain)
627
+ .values({
628
+ senderId: actorId,
629
+ amount: totalDistributed,
630
+ perRecipient,
631
+ currency: debit.currency,
632
+ roomId: input.roomId,
633
+ recipientCount: recipients.length,
634
+ })
635
+ .returning();
636
+ if (!rainRow) {
637
+ throw new RainCreditError();
638
+ }
639
+ await tx.insert(playerRainReceiver).values(recipients.map((recipientId) => ({
640
+ rainId: rainRow.id,
641
+ recipientId,
642
+ amount: perRecipient,
643
+ })));
644
+ const message = await this.systemWriter.postSystemMessage({
645
+ roomId: input.roomId,
646
+ actorId,
647
+ tx,
648
+ metadata: {
649
+ command: 'rain',
650
+ fromUserId: actorId,
651
+ fromUsername: sender.username,
652
+ amount: totalDistributed,
653
+ currency: debit.currency,
654
+ recipientCount: recipients.length,
655
+ perRecipient,
656
+ recipients: recipientDetails,
657
+ },
658
+ });
659
+ await this.audit.recordInTransaction(tx, {
660
+ actorId,
661
+ actorType: 'player',
662
+ action: 'chat.rain',
663
+ resourceType: 'player_rain',
664
+ resourceId: rainRow.id,
665
+ before: null,
666
+ after: { amount: totalDistributed, recipientCount: recipients.length },
667
+ });
668
+ return {
669
+ msg: message,
670
+ currency: debit.currency,
671
+ totalDistributed,
672
+ perRecipient,
673
+ recipientIds: recipients,
674
+ };
675
+ });
676
+ });
677
+ await this.completeCommandIdempotency('rain', actorId, input.idempotencyKey, fingerprint, msg);
678
+ // The caller now owns the commit boundary: postSystemMessage was passed `tx` above so
679
+ // it did not auto-publish - publish only now that this transaction has committed.
680
+ publishChatEvent(this.transport, input.roomId, msg);
681
+ void this.events.emit('chat.rain.distributed', {
682
+ fromUserId: actorId,
683
+ recipients: recipientIds,
684
+ recipientCount: recipientIds.length,
685
+ totalAmount: totalDistributed,
686
+ currency,
687
+ roomId: input.roomId,
688
+ });
689
+ return msg;
690
+ }
691
+ async doSendDonate(input, actorId) {
692
+ await this.verifyRoomAccessIfNeeded(input.roomId, actorId);
693
+ const config = await this.loadCommandConfig('donate');
694
+ if (config?.maxAmount !== undefined &&
695
+ moneyToNumber(input.amount) > moneyToNumber(config.maxAmount)) {
696
+ throw new ExceedsLimitError();
697
+ }
698
+ if (config?.minAmount !== undefined &&
699
+ moneyToNumber(input.amount) < moneyToNumber(config.minAmount)) {
700
+ throw new BelowMinimumError();
701
+ }
702
+ const target = await this.resolveExactPlayer(input.targetUsername);
703
+ if (target.userId === actorId) {
704
+ throw new DonateSelfError();
705
+ }
706
+ const senderSummaries = await this.directory.lookupPlayers([actorId]);
707
+ const sender = senderSummaries.find((s) => s.userId === actorId);
708
+ if (!sender) {
709
+ throw new ChatPlayerNotFoundError(actorId);
710
+ }
711
+ const fingerprint = fingerprintCommand({ type: 'donate', ...input });
712
+ const replay = await this.findCommandReplay('donate', actorId, input.idempotencyKey, fingerprint);
713
+ if (replay) {
714
+ return replay;
715
+ }
716
+ await this.reserveCommandIdempotency('donate', actorId, input.idempotencyKey, fingerprint);
717
+ const { msg, currency } = await this.runWithCommandReservation('donate', actorId, input.idempotencyKey, () => this.drizzle.db.transaction(async (tx) => {
718
+ const debit = await this.wallet.debit(tx, {
719
+ userId: actorId,
720
+ amount: input.amount,
721
+ type: 'tip',
722
+ });
723
+ if (!debit.ok) {
724
+ throw new InsufficientBalanceError();
725
+ }
726
+ const credit = await this.wallet.credit(tx, {
727
+ userId: target.userId,
728
+ amount: input.amount,
729
+ currency: debit.currency,
730
+ type: 'tip',
731
+ });
732
+ if (!credit.ok) {
733
+ throw new ChatPlayerNotFoundError(target.userId);
734
+ }
735
+ const message = await this.systemWriter.postSystemMessage({
736
+ roomId: input.roomId,
737
+ actorId,
738
+ tx,
739
+ metadata: {
740
+ command: 'donate',
741
+ senderId: actorId,
742
+ senderUsername: sender.username,
743
+ recipientId: target.userId,
744
+ recipientUsername: target.username,
745
+ amount: input.amount,
746
+ currency: debit.currency,
747
+ },
748
+ });
749
+ const [donateRow] = await tx
750
+ .insert(playerDonate)
751
+ .values({
752
+ senderId: actorId,
753
+ senderUsername: sender.username,
754
+ recipientId: target.userId,
755
+ recipientUsername: target.username,
756
+ amount: input.amount,
757
+ currency: debit.currency,
758
+ roomId: input.roomId,
759
+ })
760
+ .returning();
761
+ if (!donateRow) {
762
+ throw new ChatPlayerNotFoundError(target.userId);
763
+ }
764
+ await this.audit.recordInTransaction(tx, {
765
+ actorId,
766
+ actorType: 'player',
767
+ action: 'chat.donate',
768
+ resourceType: 'player_donate',
769
+ resourceId: donateRow.id,
770
+ before: null,
771
+ after: { recipientId: target.userId, amount: input.amount, currency: debit.currency },
772
+ });
773
+ return { msg: message, currency: debit.currency };
774
+ }));
775
+ await this.completeCommandIdempotency('donate', actorId, input.idempotencyKey, fingerprint, msg);
776
+ publishChatEvent(this.transport, input.roomId, msg);
777
+ void this.events.emit('chat.donate.sent', {
778
+ senderId: actorId,
779
+ senderUsername: sender.username,
780
+ recipientId: target.userId,
781
+ recipientUsername: target.username,
782
+ amount: input.amount,
783
+ currency,
784
+ roomId: input.roomId,
785
+ });
786
+ return msg;
787
+ }
788
+ }
789
+ //# sourceMappingURL=social-transfers.service.js.map