From 012fc136f4066a0ef63c109486149c7c1c3299c4 Mon Sep 17 00:00:00 2001 From: Olek Date: Sat, 8 Aug 2026 00:42:45 +0200 Subject: [PATCH] fix(chat): adding metadata to system messages and more --- docs/catalog.json | 4 + .../contracts/adapters/chat-system-writer.ts | 19 +++- .../src/contracts/adapters/gift-commands.ts | 4 +- packages/core/src/contracts/adapters/index.ts | 6 +- .../src/contracts/adapters/rain-commands.ts | 4 +- .../schemas/chat-command-metadata.ts | 17 ++++ .../src/contracts/schemas/chat-command.ts | 14 +++ .../chat-commands/contract/index.ts | 7 +- .../__tests__/chat-commands.service.test.ts | 13 ++- .../service/chat-commands.service.ts | 6 +- .../engagement/chat/service/chat.service.ts | 45 +++++++++ .../social-transfers/contract/index.ts | 5 +- .../social-transfers.service.test.ts | 95 +++++++++++++++---- .../service/social-transfers.service.ts | 87 ++++++++++++----- 14 files changed, 271 insertions(+), 55 deletions(-) diff --git a/docs/catalog.json b/docs/catalog.json index ac2d2989..2f4f73aa 100644 --- a/docs/catalog.json +++ b/docs/catalog.json @@ -1032,6 +1032,10 @@ "name": "ClientMetaSchema", "file": "packages/core/src/contracts/schemas/common.ts" }, + { + "name": "CommandChatMessageSchema", + "file": "packages/core/src/contracts/schemas/chat-command.ts" + }, { "name": "CommandConfigSchema", "file": "packages/core/src/engagement/chat-commands/contract/index.ts" diff --git a/packages/core/src/contracts/adapters/chat-system-writer.ts b/packages/core/src/contracts/adapters/chat-system-writer.ts index c3cd9117..f967fa20 100644 --- a/packages/core/src/contracts/adapters/chat-system-writer.ts +++ b/packages/core/src/contracts/adapters/chat-system-writer.ts @@ -1,7 +1,12 @@ import { createToken } from './token.js'; -import type { SystemChatMessage, CommandMetadata } from '../schemas/chat-command.js'; +import type { + SystemChatMessage, + CommandChatMessage, + CommandMetadata, +} from '../schemas/chat-command.js'; export type { SystemChatMessage as ChatSystemMessage }; +export type { CommandChatMessage }; export type ChatSystemWriter = { postSystemMessage(args: { @@ -10,6 +15,18 @@ export type ChatSystemWriter = { metadata: CommandMetadata; tx?: unknown; }): Promise; + postCommandMessage(args: { + roomId: string | null; + userId: string; + username: string; + metadata: CommandMetadata; + tx?: unknown; + }): Promise; + updateCommandMessage(args: { + messageId: string; + metadata: CommandMetadata; + tx?: unknown; + }): Promise; }; export const CHAT_SYSTEM_WRITER = createToken('ChatSystemWriter'); diff --git a/packages/core/src/contracts/adapters/gift-commands.ts b/packages/core/src/contracts/adapters/gift-commands.ts index f79f08e8..7afe201a 100644 --- a/packages/core/src/contracts/adapters/gift-commands.ts +++ b/packages/core/src/contracts/adapters/gift-commands.ts @@ -8,7 +8,7 @@ // AGENTS.md). import { createToken, type Token } from './token.js'; import type { Uuid } from '../schemas/common.js'; -import type { ChatSystemMessage as SystemChatMessage } from './chat-system-writer.js'; +import type { CommandChatMessage } from './chat-system-writer.js'; export type SendGiftArgs = { amount: string; @@ -27,7 +27,7 @@ export type SendGiftFailureReason = | 'room_not_member'; export type SendGiftResult = - | { ok: true; message: SystemChatMessage } + | { ok: true; message: CommandChatMessage } | { ok: false; reason: SendGiftFailureReason }; export type ClaimGiftFailureReason = diff --git a/packages/core/src/contracts/adapters/index.ts b/packages/core/src/contracts/adapters/index.ts index c6e0f4ad..5be8026c 100644 --- a/packages/core/src/contracts/adapters/index.ts +++ b/packages/core/src/contracts/adapters/index.ts @@ -199,7 +199,11 @@ export { LOGIN_ENFORCEMENT } from './login-enforcement.js'; export type { PlayEligibilityPort } from './play-eligibility.js'; export { PLAY_ELIGIBILITY } from './play-eligibility.js'; -export type { ChatSystemMessage, ChatSystemWriter } from './chat-system-writer.js'; +export type { + ChatSystemMessage, + CommandChatMessage, + ChatSystemWriter, +} from './chat-system-writer.js'; export { CHAT_SYSTEM_WRITER } from './chat-system-writer.js'; export type { ChatBlockWriter } from './chat-block-writer.js'; diff --git a/packages/core/src/contracts/adapters/rain-commands.ts b/packages/core/src/contracts/adapters/rain-commands.ts index f5370ef5..45fa994a 100644 --- a/packages/core/src/contracts/adapters/rain-commands.ts +++ b/packages/core/src/contracts/adapters/rain-commands.ts @@ -9,7 +9,7 @@ // import. ADR-0017. import { createToken, type Token } from './token.js'; import type { Uuid } from '../schemas/common.js'; -import type { ChatSystemMessage } from './chat-system-writer.js'; +import type { CommandChatMessage } from './chat-system-writer.js'; export type SendRainArgs = { amount: string; @@ -36,7 +36,7 @@ export type SendRainFailureReason = | 'room_not_member'; export type SendRainResult = - | { ok: true; message: ChatSystemMessage } + | { ok: true; message: CommandChatMessage } | { ok: false; reason: SendRainFailureReason }; export type RainCommands = { diff --git a/packages/core/src/contracts/schemas/chat-command-metadata.ts b/packages/core/src/contracts/schemas/chat-command-metadata.ts index 30b092ae..457f2046 100644 --- a/packages/core/src/contracts/schemas/chat-command-metadata.ts +++ b/packages/core/src/contracts/schemas/chat-command-metadata.ts @@ -16,16 +16,31 @@ export const GiftCommandMetadataSchema = z.object({ senderUsername: z.string(), amount: MoneyAmountSchema, currency: z.string(), + // Optional for compatibility with gift messages persisted before claim state + // was included in command metadata. New gift messages always populate these. + status: z.enum(['available', 'claimed']).optional(), + claimedBy: UuidSchema.nullable().optional(), + claimedByUsername: z.string().nullable().optional(), + claimedAt: z.string().nullable().optional(), }); export type GiftCommandMetadata = z.infer; export const RainCommandMetadataSchema = z.object({ command: z.literal('rain'), fromUserId: UuidSchema, + fromUsername: z.string().optional(), amount: MoneyAmountSchema, currency: z.string(), recipientCount: z.number().int(), perRecipient: MoneyAmountSchema, + recipients: z + .array( + z.object({ + userId: UuidSchema, + username: z.string(), + }), + ) + .optional(), }); export type RainCommandMetadata = z.infer; @@ -59,6 +74,8 @@ export type UnignoreCommandMetadata = z.infer; +/** A user-authored chat message carrying structured command metadata. */ +export const CommandChatMessageSchema = z.object({ + id: UuidSchema, + roomId: UuidSchema.nullable(), + userId: UuidSchema, + username: z.string(), + content: z.string(), + type: z.literal('user'), + metadata: CommandMetadataSchema, + isDeleted: z.boolean(), + createdAt: z.string(), +}); +export type CommandChatMessage = z.infer; + /** * Client-facing sentinel for global chat wherever a chat command's `roomId` * field is otherwise a real room UUID (`/gift`, `/rain`, `/donate`) - the diff --git a/packages/core/src/engagement/chat-commands/contract/index.ts b/packages/core/src/engagement/chat-commands/contract/index.ts index 99bbf93a..eb0f934a 100644 --- a/packages/core/src/engagement/chat-commands/contract/index.ts +++ b/packages/core/src/engagement/chat-commands/contract/index.ts @@ -4,6 +4,7 @@ import { UuidSchema, MoneyAmountSchema, SystemChatMessageSchema, + CommandChatMessageSchema, ChatRoomIdSchema, TimestampSchema, } from '@openora/core/contracts'; @@ -50,7 +51,7 @@ export const MentionResultSchema = z.object({ }); export type MentionResult = z.infer; -export { SystemChatMessageSchema }; +export { SystemChatMessageSchema, CommandChatMessageSchema }; export const PostGiftInputSchema = z.object({ amount: MoneyAmountSchema, @@ -99,7 +100,7 @@ export const chatCommandsContract = { postGift: oc .route({ method: 'POST', path: '/chat-command/gift' }) .input(PostGiftInputSchema) - .output(SystemChatMessageSchema), + .output(CommandChatMessageSchema), claimGift: oc .route({ method: 'POST', path: '/chat-command/gift/{id}/claim' }) @@ -118,7 +119,7 @@ export const chatCommandsContract = { postRain: oc .route({ method: 'POST', path: '/chat-command/rain' }) .input(PostRainInputSchema) - .output(SystemChatMessageSchema), + .output(CommandChatMessageSchema), mentionSearch: oc .route({ method: 'GET', path: '/chat-command/mention-search' }) diff --git a/packages/core/src/engagement/chat-commands/service/__tests__/chat-commands.service.test.ts b/packages/core/src/engagement/chat-commands/service/__tests__/chat-commands.service.test.ts index bdd47870..99253b2e 100644 --- a/packages/core/src/engagement/chat-commands/service/__tests__/chat-commands.service.test.ts +++ b/packages/core/src/engagement/chat-commands/service/__tests__/chat-commands.service.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { mock, makeDrizzle } from '../../../../testing/mock.js'; import type { - ChatSystemMessage, + CommandChatMessage, ChatBlockWriter, AdminUserDirectory, GiftCommands, @@ -41,11 +41,14 @@ const ENABLED_ROW = { const DISABLED_ROW = { ...ENABLED_ROW, enabled: false }; -const SYSTEM_MSG: ChatSystemMessage = { +const SYSTEM_MSG: CommandChatMessage = { id: MSG_ID, roomId: ROOM_ID, - actorId: ACTOR_ID, + userId: ACTOR_ID, + username: 'bob', content: '', + type: 'user', + isDeleted: false, metadata: { command: 'gift', giftId: GIFT_ID, @@ -53,6 +56,10 @@ const SYSTEM_MSG: ChatSystemMessage = { senderUsername: 'bob', amount: '10.00000000', currency: 'USD', + status: 'available', + claimedBy: null, + claimedByUsername: null, + claimedAt: null, }, createdAt: new Date().toISOString(), }; diff --git a/packages/core/src/engagement/chat-commands/service/chat-commands.service.ts b/packages/core/src/engagement/chat-commands/service/chat-commands.service.ts index 46dae631..d548a78c 100644 --- a/packages/core/src/engagement/chat-commands/service/chat-commands.service.ts +++ b/packages/core/src/engagement/chat-commands/service/chat-commands.service.ts @@ -9,7 +9,7 @@ import { } from '@openora/core/server'; import type { Uuid, - ChatSystemMessage, + CommandChatMessage, ChatBlockWriter, AdminUserDirectory, GiftCommands, @@ -193,7 +193,7 @@ export class ChatCommandsService { return summaries.map((s) => ({ userId: s.userId, username: s.username })); } - async postGift(input: PostGiftInput, actorId: Uuid): Promise { + async postGift(input: PostGiftInput, actorId: Uuid): Promise { const result = await this.giftCommands.sendGift(input, actorId); if (result.ok) { return result.message; @@ -258,7 +258,7 @@ export class ChatCommandsService { // who is online (it owns presence for the whole chat-command surface via // its own dependency on `chat`) and translates the port's discriminated // result into the typed errors this module's router maps to transport codes. - async postRain(input: PostRainInput, actorId: Uuid): Promise { + async postRain(input: PostRainInput, actorId: Uuid): Promise { const onlineUserIds = await this.transport.getOnlineUserIds(chatChannel(input.roomId)); const result = await this.rainCommands.sendRain({ ...input, onlineUserIds }, actorId); if (result.ok) { diff --git a/packages/core/src/engagement/chat/service/chat.service.ts b/packages/core/src/engagement/chat/service/chat.service.ts index 93347000..a8f399ec 100644 --- a/packages/core/src/engagement/chat/service/chat.service.ts +++ b/packages/core/src/engagement/chat/service/chat.service.ts @@ -18,6 +18,7 @@ import type { RealtimeTransport, CommandMetadata, ChatSystemMessage, + CommandChatMessage, AdminUserDirectory, } from '@openora/core/contracts'; import { chatChannel } from '@openora/core/contracts'; @@ -1185,6 +1186,50 @@ export class ChatService { return msg; } + async postCommandMessage(args: { + roomId: ChatRoom['id'] | null; + userId: User['id']; + username: string; + metadata: CommandMetadata; + tx?: unknown; + }): Promise { + const db = (args.tx as DrizzleDb | undefined) ?? this.drizzle.db; + const [record] = await db + .insert(chatMessage) + .values({ + roomId: args.roomId, + userId: args.userId, + username: args.username, + content: '', + type: 'user', + metadata: args.metadata, + }) + .returning(); + const message = toMessage(record); + if (!args.tx) { + void this.transport.publish(chatChannel(args.roomId), message); + } + return message as CommandChatMessage; + } + + async updateCommandMessage(args: { + messageId: ChatMessage['id']; + metadata: CommandMetadata; + tx?: unknown; + }): Promise { + const db = (args.tx as DrizzleDb | undefined) ?? this.drizzle.db; + const [record] = await db + .update(chatMessage) + .set({ metadata: args.metadata }) + .where(eq(chatMessage.id, args.messageId)) + .returning(); + const message = toMessage(record); + if (!args.tx) { + void this.transport.publish(chatChannel(message.roomId), message); + } + return message as CommandChatMessage; + } + async listRoomMembers({ roomId, viewerId }: { roomId: ChatRoom['id']; viewerId?: User['id'] }) { await this.verifyRoomAccess(roomId, viewerId); const members = await this.drizzle.db diff --git a/packages/core/src/engagement/social-transfers/contract/index.ts b/packages/core/src/engagement/social-transfers/contract/index.ts index a32849de..dfa1e93f 100644 --- a/packages/core/src/engagement/social-transfers/contract/index.ts +++ b/packages/core/src/engagement/social-transfers/contract/index.ts @@ -4,10 +4,11 @@ import { UuidSchema, MoneyAmountSchema, SystemChatMessageSchema, + CommandChatMessageSchema, ChatRoomIdSchema, } from '@openora/core/contracts'; -export { SystemChatMessageSchema }; +export { SystemChatMessageSchema, CommandChatMessageSchema }; export const SendDonateInputSchema = z.object({ targetUsername: z.string().min(1), @@ -25,5 +26,5 @@ export const socialTransfersContract = { sendDonate: oc .route({ method: 'POST', path: '/social-transfers/donate' }) .input(SendDonateInputSchema) - .output(SystemChatMessageSchema), + .output(CommandChatMessageSchema), }; diff --git a/packages/core/src/engagement/social-transfers/service/__tests__/social-transfers.service.test.ts b/packages/core/src/engagement/social-transfers/service/__tests__/social-transfers.service.test.ts index be83f7b6..8cd20f3a 100644 --- a/packages/core/src/engagement/social-transfers/service/__tests__/social-transfers.service.test.ts +++ b/packages/core/src/engagement/social-transfers/service/__tests__/social-transfers.service.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { mock, makeDrizzle, makeEventBus } from '../../../../testing/mock.js'; import type { - ChatSystemMessage, + CommandChatMessage, ChatSystemWriter, WalletCommands, AdminUserDirectory, @@ -21,6 +21,7 @@ import { const ACTOR_ID = '00000000-0000-0000-0000-000000000001'; const CLAIMER_ID = '00000000-0000-0000-0000-000000000002'; +const RECIPIENT_2 = '00000000-0000-0000-0000-000000000006'; const ROOM_ID = '00000000-0000-0000-0000-000000000003'; const MSG_ID = '00000000-0000-0000-0000-000000000004'; const GIFT_ID = '00000000-0000-0000-0000-000000000005'; @@ -37,11 +38,14 @@ const ENABLED_ROW = { const DISABLED_ROW = { ...ENABLED_ROW, enabled: false }; -const SYSTEM_MSG: ChatSystemMessage = { +const SYSTEM_MSG: CommandChatMessage = { id: MSG_ID, roomId: ROOM_ID, - actorId: ACTOR_ID, + userId: ACTOR_ID, + username: 'bob', content: '', + type: 'user', + isDeleted: false, metadata: { command: 'gift', giftId: GIFT_ID, @@ -49,6 +53,10 @@ const SYSTEM_MSG: ChatSystemMessage = { senderUsername: 'bob', amount: '10.00000000', currency: 'USD', + status: 'available', + claimedBy: null, + claimedByUsername: null, + claimedAt: null, }, createdAt: new Date().toISOString(), }; @@ -70,7 +78,8 @@ const GIFT_ROW = { function makeWriter(): ChatSystemWriter { return mock({ - postSystemMessage: vi.fn().mockResolvedValue(SYSTEM_MSG), + postCommandMessage: vi.fn().mockResolvedValue(SYSTEM_MSG), + updateCommandMessage: vi.fn().mockResolvedValue(SYSTEM_MSG), }); } @@ -89,7 +98,11 @@ function makeWallet(ok = true): WalletCommands { const DIRECTORY_CREATED_AT = new Date('2026-01-01T00:00:00.000Z'); -function makeDirectory(senderUsername = 'bob', claimerUsername = 'alice'): AdminUserDirectory { +function makeDirectory( + senderUsername = 'bob', + claimerUsername = 'alice', + extraUserIds: string[] = [], +): AdminUserDirectory { const all = [ { userId: ACTOR_ID, @@ -113,6 +126,28 @@ function makeDirectory(senderUsername = 'bob', claimerUsername = 'alice'): Admin level: 1, currency: 'USD', }, + { + userId: RECIPIENT_2, + username: 'charlie', + email: 'charlie@example.com', + kycStatus: null, + language: null, + avatarUrl: null, + createdAt: DIRECTORY_CREATED_AT, + level: 1, + currency: 'USD', + }, + ...extraUserIds.map((userId, index) => ({ + userId, + username: `player${index}`, + email: `player${index}@example.com`, + kycStatus: null, + language: null, + avatarUrl: null, + createdAt: DIRECTORY_CREATED_AT, + level: 1, + currency: 'USD', + })), ]; return mock({ findPlayerIds: vi.fn().mockResolvedValue([ACTOR_ID]), @@ -253,8 +288,8 @@ describe('SocialTransfersService.sendGift (GIFT_COMMANDS port)', () => { { amount: '10.00000000', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, ACTOR_ID, ); - expect(writer.postSystemMessage).toHaveBeenCalledOnce(); - expect(writer.postSystemMessage).toHaveBeenCalledWith( + expect(writer.postCommandMessage).toHaveBeenCalledOnce(); + expect(writer.postCommandMessage).toHaveBeenCalledWith( expect.objectContaining({ metadata: expect.objectContaining({ command: 'gift', @@ -368,6 +403,7 @@ describe('SocialTransfersService.sendGift idempotency', () => { describe('SocialTransfersService.claimGift', () => { it('credits the claimer and returns { ok: true } on happy path', async () => { const wallet = makeWallet(); + const writer = makeWriter(); const svc = makeSvc({ drizzleRows: { select: [[GIFT_ROW]], @@ -383,6 +419,7 @@ describe('SocialTransfersService.claimGift', () => { ], }, wallet, + writer, }); const result = await svc.claimGift(GIFT_ID, CLAIMER_ID); expect(wallet.credit).toHaveBeenCalledOnce(); @@ -396,6 +433,17 @@ describe('SocialTransfersService.claimGift', () => { expect(result.claimedByUsername).toBe('alice'); expect(result.claimedAt).toEqual(expect.any(String)); } + expect(writer.updateCommandMessage).toHaveBeenCalledWith( + expect.objectContaining({ + messageId: MSG_ID, + metadata: expect.objectContaining({ + command: 'gift', + status: 'claimed', + claimedBy: CLAIMER_ID, + claimedByUsername: 'alice', + }), + }), + ); }); it('returns { ok: false, reason: "self_claim" } when the sender tries to claim their own gift', async () => { @@ -528,7 +576,6 @@ describe('SocialTransfersService.sendRain (RAIN_COMMANDS port)', () => { }); it('distributes to online recipients excluding the actor, persists a player_rain row + receivers, posts a system message with rain metadata inside the transaction, and publishes after commit', async () => { - const RECIPIENT_2 = '00000000-0000-0000-0000-000000000006'; const wallet = makeWallet(); const writer = makeWriter(); const transport = makeTransport(); @@ -560,19 +607,25 @@ describe('SocialTransfersService.sendRain (RAIN_COMMANDS port)', () => { currency: 'USD', type: 'rain', }); - expect(writer.postSystemMessage).toHaveBeenCalledOnce(); - expect(writer.postSystemMessage).toHaveBeenCalledWith( + expect(writer.postCommandMessage).toHaveBeenCalledOnce(); + expect(writer.postCommandMessage).toHaveBeenCalledWith( expect.objectContaining({ roomId: ROOM_ID, - actorId: ACTOR_ID, - metadata: { + userId: ACTOR_ID, + username: 'bob', + metadata: expect.objectContaining({ command: 'rain', fromUserId: ACTOR_ID, + fromUsername: 'bob', amount: '10.00000000', currency: 'USD', recipientCount: 2, perRecipient: '5.00000000', - }, + recipients: expect.arrayContaining([ + { userId: CLAIMER_ID, username: 'alice' }, + { userId: RECIPIENT_2, username: 'charlie' }, + ]), + }), }), ); expect(transport.publish).toHaveBeenCalledWith(`chat:room:${ROOM_ID}`, SYSTEM_MSG); @@ -587,6 +640,11 @@ describe('SocialTransfersService.sendRain (RAIN_COMMANDS port)', () => { execute: [[{ per_recipient: '1.00000000', total_distributed: '10.00000000' }]], }, wallet, + directory: makeDirectory( + 'bob', + 'alice', + Array.from({ length: 10 }, (_, i) => `00000000-0000-0000-0000-0000000000${10 + i}`), + ), }); await svc.sendRain( { @@ -684,13 +742,18 @@ const DONATE_ROW = { updatedAt: new Date(), }; -const DONATE_SYSTEM_MSG: ChatSystemMessage = { +const DONATE_SYSTEM_MSG: CommandChatMessage = { id: MSG_ID, roomId: ROOM_ID, - actorId: ACTOR_ID, + userId: ACTOR_ID, + username: 'bob', content: '', + type: 'user', + isDeleted: false, metadata: { command: 'donate', + senderId: ACTOR_ID, + senderUsername: 'bob', recipientId: CLAIMER_ID, recipientUsername: 'alice', amount: '10.00000000', @@ -746,7 +809,7 @@ describe('SocialTransfersService.sendDonate', () => { it('debits sender, credits recipient, and returns the system message on success', async () => { const wallet = makeWallet(); const writer = mock({ - postSystemMessage: vi.fn().mockResolvedValue(DONATE_SYSTEM_MSG), + postCommandMessage: vi.fn().mockResolvedValue(DONATE_SYSTEM_MSG), }); const svc = makeSvc({ drizzleRows: { diff --git a/packages/core/src/engagement/social-transfers/service/social-transfers.service.ts b/packages/core/src/engagement/social-transfers/service/social-transfers.service.ts index 4e996f14..2ebbf7ae 100644 --- a/packages/core/src/engagement/social-transfers/service/social-transfers.service.ts +++ b/packages/core/src/engagement/social-transfers/service/social-transfers.service.ts @@ -12,7 +12,7 @@ import { } from '@openora/core/server'; import type { Uuid, - ChatSystemMessage, + CommandChatMessage, ChatSystemWriter, WalletCommands, AdminUserDirectory, @@ -119,7 +119,7 @@ type MoneyMovingInput = const COMMAND_IDEMPOTENCY_TTL_MS = 5 * 60 * 1000; type CommandIdempotencyRecord = { fingerprint: string; - result: ChatSystemMessage | null; + result: CommandChatMessage | null; }; // The replay guard must match on the COMPLETE request, not just the amount - a reused @@ -315,7 +315,7 @@ export class SocialTransfersService implements GiftCommands, RainCommands { } } - async sendDonate(input: SendDonateInput, actorId: Uuid): Promise { + async sendDonate(input: SendDonateInput, actorId: Uuid): Promise { return this.doSendDonate(input, actorId); } @@ -350,7 +350,7 @@ export class SocialTransfersService implements GiftCommands, RainCommands { actorId: Uuid, idempotencyKey: Uuid, fingerprint: string, - ): Promise { + ): Promise { const record = await this.cache.get( this.idempotencyCacheKey(commandType, actorId, idempotencyKey), ); @@ -395,7 +395,7 @@ export class SocialTransfersService implements GiftCommands, RainCommands { actorId: Uuid, idempotencyKey: Uuid, fingerprint: string, - result: ChatSystemMessage, + result: CommandChatMessage, ): Promise { await this.cache.set( this.idempotencyCacheKey(commandType, actorId, idempotencyKey), @@ -430,7 +430,7 @@ export class SocialTransfersService implements GiftCommands, RainCommands { return summary; } - private async doSendGift(input: GiftArgs, actorId: Uuid): Promise { + private async doSendGift(input: GiftArgs, actorId: Uuid): Promise { await this.verifyRoomAccessIfNeeded(input.roomId, actorId); const config = await this.loadCommandConfig('gift'); if ( @@ -497,9 +497,10 @@ export class SocialTransfersService implements GiftCommands, RainCommands { throw new InsufficientBalanceError(); } - const systemMsg = await this.systemWriter.postSystemMessage({ + const message = await this.systemWriter.postCommandMessage({ roomId: input.roomId, - actorId, + userId: actorId, + username: senderUsername, tx, metadata: { command: 'gift', @@ -508,12 +509,16 @@ export class SocialTransfersService implements GiftCommands, RainCommands { senderUsername, amount: input.amount, currency: debit.currency, + status: 'available', + claimedBy: null, + claimedByUsername: null, + claimedAt: null, }, }); await tx .update(playerGift) - .set({ messageId: systemMsg.id }) + .set({ messageId: message.id }) .where(eq(playerGift.id, giftRow.id)); await this.audit.recordInTransaction(tx, { @@ -526,12 +531,12 @@ export class SocialTransfersService implements GiftCommands, RainCommands { after: { amount: input.amount, roomId: input.roomId }, }); - return { msg: systemMsg, giftId: giftRow.id, currency: debit.currency }; + return { msg: message, giftId: giftRow.id, currency: debit.currency }; }), ); await this.completeCommandIdempotency('gift', actorId, input.idempotencyKey, fingerprint, msg); - // The caller now owns the commit boundary: postSystemMessage was passed `tx` above so + // The caller now owns the commit boundary: postCommandMessage was passed `tx` above so // it did not auto-publish - publish only now that this transaction has committed. void this.transport.publish(chatChannel(input.roomId), msg); @@ -556,7 +561,7 @@ export class SocialTransfersService implements GiftCommands, RainCommands { } const claimerUsername = claimerSummary.username; - const { claimed, currency, roomId, senderId } = await this.drizzle.db.transaction( + const { claimed, currency, roomId, senderId, message } = await this.drizzle.db.transaction( async (tx) => { // FOR UPDATE serializes concurrent claims against the same gift row under READ COMMITTED. const giftRow = findOneOrThrow( @@ -611,17 +616,36 @@ export class SocialTransfersService implements GiftCommands, RainCommands { after: { claimedBy: claimerId, amount: updated.amount }, }); + const updatedMessage = await this.systemWriter.updateCommandMessage({ + messageId: giftRow.messageId, + tx, + metadata: { + command: 'gift', + giftId: giftRow.id, + senderId: giftRow.senderId, + senderUsername: giftRow.senderUsername, + amount: giftRow.amount, + currency: updated.currency, + status: 'claimed', + claimedBy: claimerId, + claimedByUsername: claimerUsername, + claimedAt: claimedAt.toISOString(), + }, + }); + return { claimed: updated, currency: updated.currency, roomId: updated.roomId, senderId: giftRow.senderId, + message: updatedMessage, }; }, ); const claimedAtIso = claimed.claimedAt?.toISOString() ?? new Date().toISOString(); + void this.transport.publish(chatChannel(roomId), message); void this.transport.publish(chatChannel(roomId), { event: 'gift.claimed', giftId, @@ -677,7 +701,7 @@ export class SocialTransfersService implements GiftCommands, RainCommands { }; } - private async doSendRain(input: SendRainArgs, actorId: Uuid): Promise { + private async doSendRain(input: SendRainArgs, actorId: Uuid): Promise { await this.verifyRoomAccessIfNeeded(input.roomId, actorId); const config = await this.loadCommandConfig('rain'); if ( @@ -701,6 +725,11 @@ export class SocialTransfersService implements GiftCommands, RainCommands { if (input.recipientCount > amountUnits) { throw new TooManyRecipientsError(); } + const senderSummaries = await this.directory.lookupPlayers([actorId]); + const sender = senderSummaries.find((s) => s.userId === actorId); + if (!sender) { + throw new ChatPlayerNotFoundError(actorId); + } const fingerprint = fingerprintCommand({ type: 'rain', ...input }); const replay = await this.findCommandReplay('rain', actorId, input.idempotencyKey, fingerprint); if (replay) { @@ -719,7 +748,7 @@ export class SocialTransfersService implements GiftCommands, RainCommands { 'rain', actorId, input.idempotencyKey, - () => { + async () => { const recipients = shuffleArray(input.onlineUserIds.filter((id) => id !== actorId)).slice( 0, input.recipientCount, @@ -727,6 +756,14 @@ export class SocialTransfersService implements GiftCommands, RainCommands { if (recipients.length === 0) { throw new NoOnlineUsersError(); } + const recipientSummaries = await this.directory.lookupPlayers(recipients); + const recipientDetails = recipients.map((recipientId) => { + const summary = recipientSummaries.find((s) => s.userId === recipientId); + if (!summary) { + throw new ChatPlayerNotFoundError(recipientId); + } + return { userId: summary.userId, username: summary.username }; + }); return this.drizzle.db.transaction(async (tx) => { const splitResult = await tx.execute( sql`SELECT @@ -780,17 +817,20 @@ export class SocialTransfersService implements GiftCommands, RainCommands { })), ); - const systemMsg = await this.systemWriter.postSystemMessage({ + const message = await this.systemWriter.postCommandMessage({ roomId: input.roomId, - actorId, + userId: actorId, + username: sender.username, tx, metadata: { command: 'rain', fromUserId: actorId, + fromUsername: sender.username, amount: totalDistributed, currency: debit.currency, recipientCount: recipients.length, perRecipient, + recipients: recipientDetails, }, }); @@ -805,7 +845,7 @@ export class SocialTransfersService implements GiftCommands, RainCommands { }); return { - msg: systemMsg, + msg: message, currency: debit.currency, totalDistributed, perRecipient, @@ -816,7 +856,7 @@ export class SocialTransfersService implements GiftCommands, RainCommands { ); await this.completeCommandIdempotency('rain', actorId, input.idempotencyKey, fingerprint, msg); - // The caller now owns the commit boundary: postSystemMessage was passed `tx` above so + // The caller now owns the commit boundary: postCommandMessage was passed `tx` above so // it did not auto-publish - publish only now that this transaction has committed. void this.transport.publish(chatChannel(input.roomId), msg); @@ -831,7 +871,7 @@ export class SocialTransfersService implements GiftCommands, RainCommands { return msg; } - private async doSendDonate(input: DonateArgs, actorId: Uuid): Promise { + private async doSendDonate(input: DonateArgs, actorId: Uuid): Promise { await this.verifyRoomAccessIfNeeded(input.roomId, actorId); const config = await this.loadCommandConfig('donate'); if ( @@ -896,12 +936,15 @@ export class SocialTransfersService implements GiftCommands, RainCommands { throw new ChatPlayerNotFoundError(target.userId); } - const systemMsg = await this.systemWriter.postSystemMessage({ + const message = await this.systemWriter.postCommandMessage({ roomId: input.roomId, - actorId, + userId: actorId, + username: sender.username, tx, metadata: { command: 'donate', + senderId: actorId, + senderUsername: sender.username, recipientId: target.userId, recipientUsername: target.username, amount: input.amount, @@ -935,7 +978,7 @@ export class SocialTransfersService implements GiftCommands, RainCommands { after: { recipientId: target.userId, amount: input.amount, currency: debit.currency }, }); - return { msg: systemMsg, currency: debit.currency }; + return { msg: message, currency: debit.currency }; }), ); await this.completeCommandIdempotency(