diff --git a/docs/catalog.json b/docs/catalog.json index 12bd00e2..ac2d2989 100644 --- a/docs/catalog.json +++ b/docs/catalog.json @@ -48,6 +48,8 @@ "chat_user_ignore" ], "routes": [ + "chat.adminListBlockedUsers", + "chat.adminListIgnoredUsers", "chat.banMember", "chat.blockUser", "chat.createPrivateRoom", @@ -85,14 +87,14 @@ "chat_gift" ], "routes": [ + "chat-commands.adminListCommands", "chat-commands.adminUpdateCommand", "chat-commands.claimGift", - "chat-commands.execute", "chat-commands.getGift", "chat-commands.listCommands", "chat-commands.mentionSearch", - "chat-commands.playerProfile", - "chat-commands.playerSearch" + "chat-commands.postGift", + "chat-commands.postRain" ] }, { @@ -263,6 +265,8 @@ "player-management.get", "player-management.getByUserId", "player-management.list", + "player-management.playerProfile", + "player-management.playerSearch", "player-management.registrationsOverTime", "player-management.remove", "player-management.summary", @@ -291,6 +295,19 @@ "profile.update" ] }, + { + "id": "social-transfers", + "group": "engagement", + "tables": [ + "player_donate", + "player_gift", + "player_rain", + "player_rain_receiver" + ], + "routes": [ + "social-transfers.sendDonate" + ] + }, { "id": "tag", "group": "pam", @@ -348,7 +365,7 @@ "boundIn": [ "packages/core/src/admin-console/plugin.ts", "packages/core/src/casino/gaming/plugin.ts", - "packages/core/src/engagement/chat-commands/plugin.ts" + "packages/core/src/pam/player-management/plugin.ts" ] }, { @@ -382,7 +399,9 @@ "packages/core/src/engagement/chat-commands/plugin.ts", "packages/core/src/engagement/chat/plugin.ts", "packages/core/src/engagement/notifications/plugin.ts", + "packages/core/src/engagement/social-transfers/plugin.ts", "packages/core/src/pam/identity/plugin.ts", + "packages/core/src/pam/player-management/plugin.ts", "packages/core/src/pam/tag/plugin.ts", "packages/core/src/wallet/plugin.ts" ] @@ -414,6 +433,7 @@ "packages/core/src/audit/plugin.ts", "packages/core/src/compliance/plugin.ts", "packages/core/src/engagement/chat-commands/plugin.ts", + "packages/core/src/engagement/social-transfers/plugin.ts", "packages/core/src/pam/player-management/plugin.ts", "packages/core/src/pam/player-note/plugin.ts", "packages/core/src/wallet/plugin.ts" @@ -439,7 +459,7 @@ "packages/core/src/casino/lobby/plugin.ts", "packages/core/src/cms/plugin.ts", "packages/core/src/compliance/plugin.ts", - "packages/core/src/engagement/chat-commands/plugin.ts", + "packages/core/src/engagement/social-transfers/plugin.ts", "packages/core/src/iam/plugin.ts", "packages/core/src/pam/identity/plugin.ts" ] @@ -451,7 +471,8 @@ "status": "wired", "boundIn": [ "packages/core/src/engagement/chat-commands/plugin.ts", - "packages/core/src/engagement/chat/plugin.ts" + "packages/core/src/engagement/chat/plugin.ts", + "packages/core/src/pam/player-management/plugin.ts" ] }, { @@ -460,8 +481,8 @@ "token": "CHAT_ROOM_ACCESS", "status": "wired", "boundIn": [ - "packages/core/src/engagement/chat-commands/plugin.ts", - "packages/core/src/engagement/chat/plugin.ts" + "packages/core/src/engagement/chat/plugin.ts", + "packages/core/src/engagement/social-transfers/plugin.ts" ] }, { @@ -470,8 +491,8 @@ "token": "CHAT_SYSTEM_WRITER", "status": "wired", "boundIn": [ - "packages/core/src/engagement/chat-commands/plugin.ts", - "packages/core/src/engagement/chat/plugin.ts" + "packages/core/src/engagement/chat/plugin.ts", + "packages/core/src/engagement/social-transfers/plugin.ts" ] }, { @@ -522,6 +543,16 @@ "packages/core/src/compliance/plugin.ts" ] }, + { + "category": "gift-commands", + "interface": "SendGiftArgs", + "token": "GIFT_COMMANDS", + "status": "wired", + "boundIn": [ + "packages/core/src/engagement/chat-commands/plugin.ts", + "packages/core/src/engagement/social-transfers/plugin.ts" + ] + }, { "category": "identity", "interface": "IdentityLockoutOptions", @@ -623,6 +654,16 @@ "packages/core/src/wallet/plugin.ts" ] }, + { + "category": "rain-commands", + "interface": "SendRainArgs", + "token": "RAIN_COMMANDS", + "status": "wired", + "boundIn": [ + "packages/core/src/engagement/chat-commands/plugin.ts", + "packages/core/src/engagement/social-transfers/plugin.ts" + ] + }, { "category": "realtime", "interface": "RealtimePresence", @@ -669,7 +710,7 @@ "status": "wired", "boundIn": [ "packages/core/src/casino/gaming/plugin.ts", - "packages/core/src/engagement/chat-commands/plugin.ts", + "packages/core/src/engagement/social-transfers/plugin.ts", "packages/core/src/wallet/plugin.ts" ] }, @@ -747,6 +788,7 @@ "identity.user.unlocked", "notifications.created", "player.level.changed", + "player.login_blocked", "rg.cooling_off.activated", "rg.cooling_off.lifted", "rg.exclusion.login_blocked", @@ -782,6 +824,18 @@ "name": "AddGeoRuleInputSchema", "file": "packages/core/src/compliance/contract/index.ts" }, + { + "name": "AdminBlockedUserSchema", + "file": "packages/core/src/engagement/chat/contract/index.ts" + }, + { + "name": "AdminCommandSortBySchema", + "file": "packages/core/src/engagement/chat-commands/contract/index.ts" + }, + { + "name": "AdminIgnoredUserSchema", + "file": "packages/core/src/engagement/chat/contract/index.ts" + }, { "name": "AdminInvitationSchema", "file": "packages/core/src/iam/contract/index.ts" @@ -882,6 +936,10 @@ "name": "BlockedUserSchema", "file": "packages/core/src/engagement/chat/contract/index.ts" }, + { + "name": "BlockedUserSortBySchema", + "file": "packages/core/src/engagement/chat/contract/index.ts" + }, { "name": "BrandingSchema", "file": "packages/core/src/contracts/schemas/igaming-config.ts" @@ -946,6 +1004,10 @@ "name": "ChatRoomCategorySchema", "file": "packages/core/src/engagement/chat/contract/index.ts" }, + { + "name": "ChatRoomIdSchema", + "file": "packages/core/src/contracts/schemas/chat-command.ts" + }, { "name": "ChatRoomMemberSchema", "file": "packages/core/src/engagement/chat/contract/index.ts" @@ -1202,6 +1264,10 @@ "name": "IgnoredUserSchema", "file": "packages/core/src/engagement/chat/contract/index.ts" }, + { + "name": "IgnoredUserSortBySchema", + "file": "packages/core/src/engagement/chat/contract/index.ts" + }, { "name": "InvitationStatusSchema", "file": "packages/core/src/contracts/schemas/iam.ts" @@ -1424,7 +1490,7 @@ }, { "name": "PlayerProfileCardSchema", - "file": "packages/core/src/engagement/chat-commands/contract/index.ts" + "file": "packages/core/src/pam/player-management/contract/index.ts" }, { "name": "PlayerRegistrationPointSchema", @@ -1440,7 +1506,7 @@ }, { "name": "PlayerSearchResultSchema", - "file": "packages/core/src/engagement/chat-commands/contract/index.ts" + "file": "packages/core/src/pam/player-management/contract/index.ts" }, { "name": "PlayerSearchSchema", @@ -1486,6 +1552,14 @@ "name": "PositiveMoneyAmountSchema", "file": "packages/core/src/casino/gaming/contract/index.ts" }, + { + "name": "PostGiftInputSchema", + "file": "packages/core/src/engagement/chat-commands/contract/index.ts" + }, + { + "name": "PostRainInputSchema", + "file": "packages/core/src/engagement/chat-commands/contract/index.ts" + }, { "name": "ProfileCommandMetadataSchema", "file": "packages/core/src/contracts/schemas/chat-command-metadata.ts" @@ -1570,6 +1644,10 @@ "name": "SelfExcludedLoginDetailSchema", "file": "packages/core/src/compliance/contract/rg.ts" }, + { + "name": "SendDonateInputSchema", + "file": "packages/core/src/engagement/social-transfers/contract/index.ts" + }, { "name": "SessionItemSchema", "file": "packages/core/src/pam/identity/contract/index.ts" @@ -1662,6 +1740,14 @@ "name": "TransactionResultSchema", "file": "packages/core/src/wallet/contract/index.ts" }, + { + "name": "UnblockCommandMetadataSchema", + "file": "packages/core/src/contracts/schemas/chat-command-metadata.ts" + }, + { + "name": "UnignoreCommandMetadataSchema", + "file": "packages/core/src/contracts/schemas/chat-command-metadata.ts" + }, { "name": "UpdatePlayerProfileInputSchema", "file": "packages/core/src/contracts/schemas/player.ts" diff --git a/extensions.config.ts b/extensions.config.ts index 13cfd3e8..a692d345 100644 --- a/extensions.config.ts +++ b/extensions.config.ts @@ -20,6 +20,10 @@ export const extensions = [ { id: 'gaming', path: './packages/core/dist/casino/gaming/plugin.js' }, { id: 'lobby', path: './packages/core/dist/casino/lobby/plugin.js' }, { id: 'chat', path: './packages/core/dist/engagement/chat/plugin.js' }, + { + id: 'social-transfers', + path: './packages/core/dist/engagement/social-transfers/plugin.js', + }, { id: 'chat-commands', path: './packages/core/dist/engagement/chat-commands/plugin.js' }, // Player self-profile (owns the `player` table); the admin PAM surface is // player-management below, which reads that table via the /schema subpath. diff --git a/packages/core/package.json b/packages/core/package.json index 42e2596c..19fb193a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -573,6 +573,26 @@ "types": "./dist/player/server.d.ts", "import": "./dist/player/server.js", "default": "./dist/player/server.js" + }, + "./engagement/contracts/social-transfers": { + "types": "./dist/engagement/social-transfers/contract/index.d.ts", + "import": "./dist/engagement/social-transfers/contract/index.js", + "default": "./dist/engagement/social-transfers/contract/index.js" + }, + "./engagement/schema/social-transfers": { + "types": "./dist/engagement/social-transfers/schema/index.d.ts", + "import": "./dist/engagement/social-transfers/schema/index.js", + "default": "./dist/engagement/social-transfers/schema/index.js" + }, + "./engagement/plugins/social-transfers": { + "types": "./dist/engagement/social-transfers/plugin.d.ts", + "import": "./dist/engagement/social-transfers/plugin.js", + "default": "./dist/engagement/social-transfers/plugin.js" + }, + "./engagement/migrate/social-transfers": { + "types": "./dist/engagement/social-transfers/migrate.d.ts", + "import": "./dist/engagement/social-transfers/migrate.js", + "default": "./dist/engagement/social-transfers/migrate.js" } }, "publishConfig": { diff --git a/packages/core/src/audit/plugin.ts b/packages/core/src/audit/plugin.ts index 0609f172..2ddb2593 100644 --- a/packages/core/src/audit/plugin.ts +++ b/packages/core/src/audit/plugin.ts @@ -459,6 +459,19 @@ function mapEventToRecord(topic: string, p: Record): RecordInpu }; } + // System-driven login rejection for a suspended/closed player account (Backoffice- + // initiated block, not an admin action happening right now) - actorType 'system', + // outcome 'failure'. + if (topic === 'player.login_blocked') { + return { + ...base, + actorType: 'system', + resourceType: 'player', + resourceId: str(p['userId']), + result: 'failure', + }; + } + // Wallet events carry the txn ref in transactionId; surface it as resourceId so // a transaction reference is searchable (it otherwise stays buried in `after`). if (topic.startsWith('wallet.')) { @@ -557,6 +570,7 @@ const SUBSCRIBED_TOPICS: DomainEventName[] = [ 'tag.player.removed', 'tag.rule.upserted', 'player.level.changed', + 'player.login_blocked', ] as const; export default { diff --git a/packages/core/src/contracts/adapters/gift-commands.ts b/packages/core/src/contracts/adapters/gift-commands.ts new file mode 100644 index 00000000..f79f08e8 --- /dev/null +++ b/packages/core/src/contracts/adapters/gift-commands.ts @@ -0,0 +1,74 @@ +// Claimable-gift command port: `chat-commands` posts/claims gifts into the chat +// stream (they are conceptually part of the chat command surface) but owns none +// of the money/limit/idempotency logic - `social-transfers` does, behind this +// port. Same command-port pattern as WALLET_COMMANDS, ADR-0017. Results are a +// discriminated union rather than a thrown error so the failure reason can cross +// the module boundary without either side importing the other's error classes; +// the consuming router maps `reason` to its own typed error (see chat-commands +// 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'; + +export type SendGiftArgs = { + amount: string; + // null = global chat (the GLOBAL_CHAT_ROOM_ID sentinel on the wire, contracts/schemas/chat-command.ts). + roomId: Uuid | null; + idempotencyKey: Uuid; +}; + +export type SendGiftFailureReason = + | 'disabled' + | 'insufficient_balance' + | 'exceeds_limit' + | 'below_minimum' + | 'idempotency_key_reuse' + | 'concurrent_replay' + | 'room_not_member'; + +export type SendGiftResult = + | { ok: true; message: SystemChatMessage } + | { ok: false; reason: SendGiftFailureReason }; + +export type ClaimGiftFailureReason = + | 'gift_not_found' + | 'already_claimed' + | 'self_claim' + | 'room_not_member' + | 'gift_credit_failed'; + +export type ClaimGiftResult = + | { ok: true; claimedBy: Uuid; claimedByUsername: string; claimedAt: string } + // roomId is set (and only meaningful) for reason: 'room_not_member' - the gift's own + // room, since the caller only has giftId and can't otherwise build a correct + // ChatRoomNotMemberError (which reports a room, not a gift). + | { ok: false; reason: ClaimGiftFailureReason; roomId?: Uuid | null }; + +export type GiftSnapshot = { + id: Uuid; + senderId: Uuid; + senderUsername: string; + amount: string; + currency: string; + claimedBy: Uuid | null; + claimedByUsername: string | null; + claimedAt: string | null; + createdAt: string; +}; + +export type GetGiftFailureReason = 'gift_not_found' | 'room_not_member'; + +export type GetGiftResult = + | { ok: true; gift: GiftSnapshot } + // roomId mirrors ClaimGiftResult's room_not_member shape - the caller only + // has giftId in scope and can't otherwise build a correct + // ChatRoomNotMemberError. + | { ok: false; reason: GetGiftFailureReason; roomId?: Uuid | null }; + +export type GiftCommands = { + sendGift(input: SendGiftArgs, actorId: Uuid): Promise; + claimGift(giftId: Uuid, claimerId: Uuid): Promise; + getGift(giftId: Uuid, viewerId: Uuid): Promise; +}; + +export const GIFT_COMMANDS: Token = createToken('GIFT_COMMANDS'); diff --git a/packages/core/src/contracts/adapters/index.ts b/packages/core/src/contracts/adapters/index.ts index fc125d4e..c6e0f4ad 100644 --- a/packages/core/src/contracts/adapters/index.ts +++ b/packages/core/src/contracts/adapters/index.ts @@ -206,3 +206,24 @@ export type { ChatBlockWriter } from './chat-block-writer.js'; export { CHAT_BLOCK_WRITER } from './chat-block-writer.js'; export type { ChatRoomAccess } from './chat-room-access.js'; export { CHAT_ROOM_ACCESS } from './chat-room-access.js'; + +export type { + SendGiftArgs, + SendGiftFailureReason, + SendGiftResult, + ClaimGiftFailureReason, + ClaimGiftResult, + GiftSnapshot, + GetGiftFailureReason, + GetGiftResult, + GiftCommands, +} from './gift-commands.js'; +export { GIFT_COMMANDS } from './gift-commands.js'; + +export type { + SendRainArgs, + SendRainFailureReason, + SendRainResult, + RainCommands, +} from './rain-commands.js'; +export { RAIN_COMMANDS } from './rain-commands.js'; diff --git a/packages/core/src/contracts/adapters/rain-commands.ts b/packages/core/src/contracts/adapters/rain-commands.ts new file mode 100644 index 00000000..f5370ef5 --- /dev/null +++ b/packages/core/src/contracts/adapters/rain-commands.ts @@ -0,0 +1,46 @@ +// Rain command port: `chat-commands` owns presence (it already depends on `chat` +// for CHAT_BLOCK_WRITER), so it resolves which players are online in a room via +// CHAT_REALTIME_TRANSPORT and hands `social-transfers` the plain id list - social- +// transfers never queries chat's presence tracking directly. +// +// Same discriminated-result shape as GIFT_COMMANDS and the same reason it +// exists: chat-commands' router can't `instanceof`-match an error class +// defined in social-transfers without a forbidden cross-module internals +// 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'; + +export type SendRainArgs = { + amount: string; + recipientCount: number; + // null = global chat (the GLOBAL_CHAT_ROOM_ID sentinel on the wire, contracts/schemas/chat-command.ts). + roomId: Uuid | null; + idempotencyKey: Uuid; + // Every user id currently online in the room (may include the actor) - + // resolved by the caller BEFORE this port is called. social-transfers + // filters out the actor, shuffles, and caps to recipientCount itself. + onlineUserIds: Uuid[]; +}; + +export type SendRainFailureReason = + | 'disabled' + | 'insufficient_balance' + | 'exceeds_limit' + | 'below_minimum' + | 'no_online_users' + | 'too_many_recipients' + | 'rain_credit_failed' + | 'idempotency_key_reuse' + | 'concurrent_replay' + | 'room_not_member'; + +export type SendRainResult = + | { ok: true; message: ChatSystemMessage } + | { ok: false; reason: SendRainFailureReason }; + +export type RainCommands = { + sendRain(input: SendRainArgs, actorId: Uuid): Promise; +}; + +export const RAIN_COMMANDS: Token = createToken('RAIN_COMMANDS'); diff --git a/packages/core/src/contracts/schemas/chat-command-metadata.ts b/packages/core/src/contracts/schemas/chat-command-metadata.ts index fca1a247..30b092ae 100644 --- a/packages/core/src/contracts/schemas/chat-command-metadata.ts +++ b/packages/core/src/contracts/schemas/chat-command-metadata.ts @@ -36,6 +36,13 @@ export const BlockCommandMetadataSchema = z.object({ }); export type BlockCommandMetadata = z.infer; +export const UnblockCommandMetadataSchema = z.object({ + command: z.literal('unblock'), + targetUserId: UuidSchema, + displayName: z.string(), +}); +export type UnblockCommandMetadata = z.infer; + export const IgnoreCommandMetadataSchema = z.object({ command: z.literal('ignore'), targetUserId: UuidSchema, @@ -43,6 +50,13 @@ export const IgnoreCommandMetadataSchema = z.object({ }); export type IgnoreCommandMetadata = z.infer; +export const UnignoreCommandMetadataSchema = z.object({ + command: z.literal('unignore'), + targetUserId: UuidSchema, + displayName: z.string(), +}); +export type UnignoreCommandMetadata = z.infer; + export const DonateCommandMetadataSchema = z.object({ command: z.literal('donate'), recipientId: UuidSchema, diff --git a/packages/core/src/contracts/schemas/chat-command.ts b/packages/core/src/contracts/schemas/chat-command.ts index f92d7c63..2cbcce4a 100644 --- a/packages/core/src/contracts/schemas/chat-command.ts +++ b/packages/core/src/contracts/schemas/chat-command.ts @@ -5,7 +5,9 @@ import { GiftCommandMetadataSchema, RainCommandMetadataSchema, BlockCommandMetadataSchema, + UnblockCommandMetadataSchema, IgnoreCommandMetadataSchema, + UnignoreCommandMetadataSchema, DonateCommandMetadataSchema, } from './chat-command-metadata.js'; @@ -18,7 +20,9 @@ export const CommandMetadataSchema = z.discriminatedUnion('command', [ GiftCommandMetadataSchema, RainCommandMetadataSchema, BlockCommandMetadataSchema, + UnblockCommandMetadataSchema, IgnoreCommandMetadataSchema, + UnignoreCommandMetadataSchema, DonateCommandMetadataSchema, ]); export type CommandMetadata = z.infer; @@ -33,6 +37,19 @@ export const SystemChatMessageSchema = z.object({ }); export type SystemChatMessage = 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 + * single canonical way to address global chat on the wire, so callers never + * send a raw `null`. + */ +export const GLOBAL_CHAT_ROOM_ID = '__global'; + +export const ChatRoomIdSchema = z + .union([UuidSchema, z.literal(GLOBAL_CHAT_ROOM_ID)]) + .transform((value) => (value === GLOBAL_CHAT_ROOM_ID ? null : value)); +export type ChatRoomIdInput = z.input; + /** Canonical chat channel name used by both the chat and chat-commands modules. */ export function chatChannel(roomId: string | null): string { return roomId ? `chat:room:${roomId}` : 'chat:global'; diff --git a/packages/core/src/contracts/schemas/events.ts b/packages/core/src/contracts/schemas/events.ts index b9918ea5..edd97fe5 100644 --- a/packages/core/src/contracts/schemas/events.ts +++ b/packages/core/src/contracts/schemas/events.ts @@ -9,7 +9,7 @@ import { import { TagKeySchema } from './tag.js'; import { CurrencyCodeSchema, CountryCodeSchema } from './igaming-config.js'; import { PermissionLevelSchema } from './iam.js'; -import { KycStatusSchema, KycStatusSourceSchema } from './player.js'; +import { KycStatusSchema, KycStatusSourceSchema, PlayerStatusSchema } from './player.js'; // Optional request-origin metadata shared by HTTP-triggered events; both fields may be absent. const authContextBase = ClientMetaSchema.partial(); @@ -325,7 +325,7 @@ export const domainEventSchemas = { senderUsername: z.string(), amount: MoneyAmountSchema, currency: z.string(), - roomId: UuidSchema, + roomId: UuidSchema.nullable(), messageId: UuidSchema, }), 'chat.gift.claimed': z.object({ @@ -335,7 +335,7 @@ export const domainEventSchemas = { senderId: UuidSchema, amount: MoneyAmountSchema, currency: z.string(), - roomId: UuidSchema, + roomId: UuidSchema.nullable(), }), 'chat.rain.distributed': z.object({ fromUserId: UuidSchema, @@ -343,7 +343,7 @@ export const domainEventSchemas = { recipientCount: z.number().int(), totalAmount: MoneyAmountSchema, currency: z.string(), - roomId: UuidSchema, + roomId: UuidSchema.nullable(), }), 'chat.donate.sent': z.object({ senderId: UuidSchema, @@ -366,6 +366,13 @@ export const domainEventSchemas = { newLevel: z.number().int(), actorId: UuidSchema, }), + // System-driven login rejection for a player the Backoffice blocked (status + // suspended/closed). No admin acted at login time - the block was set earlier; this + // is a failure outcome. userId = the subject player; status = the blocking status. + 'player.login_blocked': authContextBase.extend({ + userId: UuidSchema, + status: PlayerStatusSchema, + }), } as const; export type DomainEventName = keyof typeof domainEventSchemas; @@ -381,9 +388,13 @@ export const domainEventVersions: Partial> = { // must never be persisted to the audit log or handed back to any caller. 'identity.session.revoked': 2, // v2: claimable gift-card mechanic - giftId/senderId/senderUsername/roomId replaces fromUserId/toUserId. - 'chat.gift.sent': 2, + // v3: roomId nullable - a gift can be sent into global chat (GLOBAL_CHAT_ROOM_ID sentinel on the wire). + 'chat.gift.sent': 3, + // v2: roomId nullable - a claimed gift's room can be global chat. + 'chat.gift.claimed': 2, // v2: recipients array added for per-player notification delivery. - 'chat.rain.distributed': 2, + // v3: roomId nullable - rain can be sent into global chat (GLOBAL_CHAT_ROOM_ID sentinel on the wire). + 'chat.rain.distributed': 3, // v2: exact decimal-string amount (+ currency), never a JS number. 'wallet.deposit.completed': 2, 'wallet.withdrawal.completed': 2, diff --git a/packages/core/src/contracts/schemas/identity.ts b/packages/core/src/contracts/schemas/identity.ts index d445209d..ff613787 100644 --- a/packages/core/src/contracts/schemas/identity.ts +++ b/packages/core/src/contracts/schemas/identity.ts @@ -39,7 +39,11 @@ export const PhoneLoginVerifyInputSchema = z.object({ rememberMe: z.boolean().optional(), }); -export const PHONE_LOGIN_ERROR_REASONS = ['otp_cancelled', 'rg_blocked'] as const; +export const PHONE_LOGIN_ERROR_REASONS = [ + 'otp_cancelled', + 'rg_blocked', + 'account_suspended', +] as const; export const PhoneLoginErrorReasonSchema = z.enum(PHONE_LOGIN_ERROR_REASONS); export const PHONE_LOGIN_OTP_INVALID_REASONS = ['expired', 'wrong_code'] as const; diff --git a/packages/core/src/engagement/chat-commands/AGENTS.md b/packages/core/src/engagement/chat-commands/AGENTS.md new file mode 100644 index 00000000..ccf1f92b --- /dev/null +++ b/packages/core/src/engagement/chat-commands/AGENTS.md @@ -0,0 +1,61 @@ +# chat-commands + +Thin chat-command routing surface: the DB-backed command registry (`chat_command_config`), +`@mention` autocomplete, and dedicated `postGift`/`claimGift`/`getGift`/`postRain` routes. All +gift/rain/donate money movement, limit checks, and idempotency live in `social-transfers` behind +the `GIFT_COMMANDS`/`RAIN_COMMANDS` command ports (ADR-0017) - this module never touches a wallet +or opens a `db.transaction` for money. See `social-transfers/AGENTS.md` for the mechanics; this +file only covers what actually lives here. + +## DB-backed command registry + +Each row in `chat_command_config` holds `enabled`, `label`, `description`, and a `config` jsonb +column (`maxAmount`, `minAmount`, `maxRecipients`) - the same row `social-transfers` reads +read-only via the sanctioned cross-module `/schema` import (`db-conventions`/`clean-architecture`). +`listCommands` filters to `enabled: true` by default. Seed data lives in `seed/index.ts` +(`seedChatCommands`). `adminListCommands` (`GET /backoffice/chat-command/commands`, paginated, all +rows including disabled) and `adminUpdateCommand` (`PATCH /backoffice/chat-command/commands/{key}`, +upserts by `key`) cover backoffice toggling/reconfiguration - both are `AdminGuard`-gated on the +`chat-command` resource (`view`/`update`) and `adminUpdateCommand` records a +`chat.command.updated` audit entry via `AUDIT_WRITER`. + +`mention` is special: it does not go through a dedicated post route. The `@username` pattern is +typed inline in a message; `GET /chat-command/mention-search` powers the type-ahead, excluding any +player the caller has blocked or ignored via `CHAT_BLOCK_WRITER.getExcludedUserIds(viewerId)`. + +## Gift/rain routes are pure delegation to social-transfers + +`postGift`, `claimGift`, `getGift`, and `postRain` all call straight into the `GIFT_COMMANDS`/ +`RAIN_COMMANDS` port (bound by `social-transfers`) and translate the port's discriminated +`{ ok, reason }` result into this module's own typed errors (`GiftNotFoundError`, +`ChatRoomNotMemberError`, etc.), which the router then maps to transport codes via `mapErrors`. +`getGift` (`GET /chat-command/gift/{id}`) is the read-only counterpart to `claimGift` - it returns +the gift's current state (claimed/unclaimed, claimed-by-whom) without moving money, and enforces +the exact same room-membership check as `claimGift`: a viewer who isn't a member of the gift's room +gets `ChatRoomNotMemberError`, not the gift state. Like every other route on this router, it +requires auth - the caller resolves `viewerId` via `getUserId(context)`, there is no +unauthenticated read path. + +`postRain` is the one route that does real work here beyond translation: it resolves the online +recipient list via `CHAT_REALTIME_TRANSPORT.getOnlineUserIds(chatChannel(roomId))` (this module +owns presence for the whole chat-command surface) BEFORE calling `RAIN_COMMANDS.sendRain` - see +`social-transfers/AGENTS.md` > "This module never queries chat's presence tracking" for why that +split exists. + +## Ports consumed + +- `ADMIN_USER_DIRECTORY` - `findPlayerIds`/`lookupPlayers` for `@mention` autocomplete. +- `CHAT_BLOCK_WRITER` - `getExcludedUserIds(viewerId)` to filter blocked/ignored players out of + mention search. +- `GIFT_COMMANDS` / `RAIN_COMMANDS` - bound by `social-transfers`; this module never implements + gift/rain mechanics itself. +- `CHAT_REALTIME_TRANSPORT` - `getOnlineUserIds(channel)` for rain recipient discovery. The + chat-scoped token, not the generic `REALTIME_TRANSPORT`. +- `AUDIT_WRITER` - `record(...)` for `adminUpdateCommand`'s `chat.command.updated` audit entry. + +## Don't + +- Don't add money-movement logic here - every gift/rain code path is a pure port delegation into + `social-transfers`; a new business rule for gift/rain belongs there, not in this service. +- Don't have `getGift` (or any route here) skip the room-membership check that `claimGift` enforces + - the two must stay symmetric so a client can't infer gift state it isn't allowed to see. diff --git a/packages/core/src/engagement/chat-commands/contract/index.ts b/packages/core/src/engagement/chat-commands/contract/index.ts index 0f4c67c0..99bbf93a 100644 --- a/packages/core/src/engagement/chat-commands/contract/index.ts +++ b/packages/core/src/engagement/chat-commands/contract/index.ts @@ -1,6 +1,13 @@ import { oc } from '@orpc/contract'; import * as z from 'zod'; -import { UuidSchema, MoneyAmountSchema, SystemChatMessageSchema } from '@openora/core/contracts'; +import { + UuidSchema, + MoneyAmountSchema, + SystemChatMessageSchema, + ChatRoomIdSchema, + TimestampSchema, +} from '@openora/core/contracts'; +import { PageQuerySchema, SortOrderSchema, paginated } from '@openora/core/contracts/kit'; export const CHAT_COMMAND_TYPES = [ 'mention', @@ -9,7 +16,9 @@ export const CHAT_COMMAND_TYPES = [ 'rain', 'donate', 'block', + 'unblock', 'ignore', + 'unignore', ] as const; export const ChatCommandTypeSchema = z.enum(CHAT_COMMAND_TYPES); export type ChatCommandType = z.infer; @@ -27,9 +36,14 @@ export const ChatCommandDescriptorSchema = z.object({ label: z.string(), description: z.string().nullable(), config: CommandConfigSchema.nullable(), + updatedAt: TimestampSchema, }); export type ChatCommandDescriptor = z.infer; +export const AdminCommandSortByValues = ['key', 'updatedAt'] as const; +export const AdminCommandSortBySchema = z.enum(AdminCommandSortByValues).default('key'); +export type AdminCommandSortBy = z.infer; + export const MentionResultSchema = z.object({ userId: UuidSchema, username: z.string(), @@ -38,6 +52,28 @@ export type MentionResult = z.infer; export { SystemChatMessageSchema }; +export const PostGiftInputSchema = z.object({ + amount: MoneyAmountSchema, + roomId: ChatRoomIdSchema, + idempotencyKey: UuidSchema, +}); +export type PostGiftInput = z.infer; + +export const PostRainInputSchema = z.object({ + amount: MoneyAmountSchema, + recipientCount: z.number().int().positive(), + roomId: ChatRoomIdSchema, + idempotencyKey: UuidSchema, +}); +export type PostRainInput = z.infer; + +export const ClaimGiftOutputSchema = z.object({ + claimedBy: UuidSchema, + claimedByUsername: z.string(), + claimedAt: z.string(), +}); +export type ClaimGiftOutput = z.infer; + export const GiftStateSchema = z.object({ id: UuidSchema, senderId: UuidSchema, @@ -51,86 +87,38 @@ export const GiftStateSchema = z.object({ }); export type GiftState = z.infer; -export const ClaimGiftOutputSchema = z.object({ - claimedBy: UuidSchema, - claimedByUsername: z.string(), - claimedAt: z.string(), -}); -export type ClaimGiftOutput = z.infer; - -export const PlayerSearchResultSchema = z.object({ - userId: UuidSchema, - username: z.string(), - avatarUrl: z.string().nullable(), - level: z.number().int(), -}); -export type PlayerSearchResult = z.infer; - -export const PlayerProfileCardSchema = z.object({ - userId: UuidSchema, - username: z.string(), - avatarUrl: z.string().nullable(), - level: z.number().int(), - joinedAt: z.string().nullable(), - totalWagered: MoneyAmountSchema.nullable(), - totalBets: z.number().int().nullable(), - currency: z.string().nullable(), -}); -export type PlayerProfileCard = z.infer; - export const chatCommandsContract = { listCommands: oc .route({ method: 'GET', path: '/chat-command/commands' }) .input(z.object({})) .output(z.array(ChatCommandDescriptorSchema)), - execute: oc - .route({ method: 'POST', path: '/chat-command/execute' }) - .input( - z.discriminatedUnion('type', [ - z.object({ - type: z.literal('gift'), - amount: MoneyAmountSchema, - roomId: UuidSchema, - idempotencyKey: UuidSchema, - }), - z.object({ - type: z.literal('rain'), - amount: MoneyAmountSchema, - recipientCount: z.number().int().positive(), - roomId: UuidSchema, - idempotencyKey: UuidSchema, - }), - z.object({ - type: z.literal('donate'), - targetUsername: z.string().min(1), - amount: MoneyAmountSchema, - roomId: UuidSchema.nullable(), - idempotencyKey: UuidSchema, - }), - z.object({ - type: z.literal('block'), - targetUsername: z.string().min(1), - roomId: UuidSchema.nullable(), - }), - z.object({ - type: z.literal('ignore'), - targetUsername: z.string().min(1), - roomId: UuidSchema.nullable(), - }), - ]), - ) + // Dedicated gift-send operation - money/limit/idempotency logic lives in the + // social-transfers module behind the GIFT_COMMANDS port; this module only + // wires the route. See AGENTS.md. + postGift: oc + .route({ method: 'POST', path: '/chat-command/gift' }) + .input(PostGiftInputSchema) .output(SystemChatMessageSchema), + claimGift: oc + .route({ method: 'POST', path: '/chat-command/gift/{id}/claim' }) + .input(z.object({ id: UuidSchema })) + .output(ClaimGiftOutputSchema), + getGift: oc .route({ method: 'GET', path: '/chat-command/gift/{id}' }) .input(z.object({ id: UuidSchema })) .output(GiftStateSchema), - claimGift: oc - .route({ method: 'POST', path: '/chat-command/gift/{id}/claim' }) - .input(z.object({ id: UuidSchema })) - .output(ClaimGiftOutputSchema), + // Dedicated rain-send operation - this module resolves the online recipient + // list (it owns presence lookups for the chat command surface) and hands it + // to the RAIN_COMMANDS port; money/limit/idempotency logic lives in + // social-transfers. See AGENTS.md. + postRain: oc + .route({ method: 'POST', path: '/chat-command/rain' }) + .input(PostRainInputSchema) + .output(SystemChatMessageSchema), mentionSearch: oc .route({ method: 'GET', path: '/chat-command/mention-search' }) @@ -142,23 +130,19 @@ export const chatCommandsContract = { ) .output(z.array(MentionResultSchema)), - playerSearch: oc - .route({ method: 'GET', path: '/chat-command/player-search' }) + adminListCommands: oc + .route({ method: 'GET', path: '/backoffice/chat-command/commands' }) .input( z.object({ - q: z.string().min(1).max(50), - limit: z.coerce.number().int().min(1).max(100).default(20), + ...PageQuerySchema.shape, + sortBy: AdminCommandSortBySchema, + sortOrder: SortOrderSchema.default('asc'), }), ) - .output(z.array(PlayerSearchResultSchema)), - - playerProfile: oc - .route({ method: 'GET', path: '/chat-command/player-profile/{userId}' }) - .input(z.object({ userId: UuidSchema })) - .output(PlayerProfileCardSchema), + .output(paginated(ChatCommandDescriptorSchema)), adminUpdateCommand: oc - .route({ method: 'PATCH', path: '/chat-command/admin/commands/{key}' }) + .route({ method: 'PATCH', path: '/backoffice/chat-command/commands/{key}' }) .input( z.object({ key: ChatCommandTypeSchema, diff --git a/packages/core/src/engagement/chat-commands/plugin.ts b/packages/core/src/engagement/chat-commands/plugin.ts index 45bf38e0..2190318f 100644 --- a/packages/core/src/engagement/chat-commands/plugin.ts +++ b/packages/core/src/engagement/chat-commands/plugin.ts @@ -1,36 +1,29 @@ -import { DRIZZLE, EVENT_BUS, ADMIN_GUARD } from '@openora/core/server'; +import { DRIZZLE, ADMIN_GUARD } from '@openora/core/server'; import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; import { - WALLET_COMMANDS, ADMIN_USER_DIRECTORY, - ADMIN_GAME_REPORTING, - AUDIT_WRITER, - CHAT_REALTIME_TRANSPORT, - CHAT_SYSTEM_WRITER, CHAT_BLOCK_WRITER, - CHAT_ROOM_ACCESS, - CACHE, + CHAT_REALTIME_TRANSPORT, + GIFT_COMMANDS, + RAIN_COMMANDS, + AUDIT_WRITER, } from '@openora/core/contracts'; import { ChatCommandsService } from './service/chat-commands.service.js'; import { createChatCommandsRouter } from './router/index.js'; export default { id: 'chat-commands', - dependsOn: ['chat', 'wallet', 'iam', 'audit', 'gaming'], + dependsOn: ['chat', 'social-transfers', 'audit'], register(ctx) { ctx.routers.add('chat-commands', (c) => { const svc = new ChatCommandsService( c.get(DRIZZLE), - c.get(CHAT_SYSTEM_WRITER), - c.get(WALLET_COMMANDS), c.get(ADMIN_USER_DIRECTORY), - c.get(AUDIT_WRITER), - c.get(CHAT_REALTIME_TRANSPORT), - c.get(EVENT_BUS), c.get(CHAT_BLOCK_WRITER), - c.get(ADMIN_GAME_REPORTING), - c.get(CHAT_ROOM_ACCESS), - c.get(CACHE), + c.get(GIFT_COMMANDS), + c.get(RAIN_COMMANDS), + c.get(CHAT_REALTIME_TRANSPORT), + c.get(AUDIT_WRITER), ); return createChatCommandsRouter(svc, c.get(ADMIN_GUARD)); }); diff --git a/packages/core/src/engagement/chat-commands/router/index.ts b/packages/core/src/engagement/chat-commands/router/index.ts index 59c1cfeb..9a17363f 100644 --- a/packages/core/src/engagement/chat-commands/router/index.ts +++ b/packages/core/src/engagement/chat-commands/router/index.ts @@ -1,22 +1,20 @@ import { implement } from '@orpc/server'; import { populateContractRouterPaths } from '@orpc/contract'; -import { mapErrors, getUserId, AdminGuard, type OssContext } from '@openora/core/server'; +import { mapErrors, getUserId, type OssContext, type AdminGuard } from '@openora/core/server'; import { chatCommandsContract } from '../contract/index.js'; import { ChatCommandsService, CommandDisabledError, - ChatPlayerNotFoundError, InsufficientBalanceError, ExceedsLimitError, BelowMinimumError, NoOnlineUsersError, + TooManyRecipientsError, RainCreditError, GiftNotFoundError, GiftAlreadyClaimedError, GiftSelfClaimError, - DonateSelfError, - TooManyRecipientsError, - SelfModerationActionError, + GiftCreditError, ChatCommandIdempotencyKeyReuseError, ConcurrentCommandReplayError, ChatRoomNotMemberError, @@ -30,34 +28,31 @@ export function createChatCommandsRouter(svc: ChatCommandsService, adminGuard: A return os.router({ listCommands: os.listCommands.handler(() => svc.listCommands()), - execute: os.execute.handler(({ input, context }) => { + adminListCommands: os.adminListCommands.handler(async ({ input, context }) => { + await adminGuard.assert(context, 'chat-command', 'view'); + return svc.adminListCommands(input); + }), + + adminUpdateCommand: os.adminUpdateCommand.handler(async ({ input, context }) => { + const { userId } = await adminGuard.assert(context, 'chat-command', 'update'); + return svc.adminUpdateCommand(input, userId); + }), + + postGift: os.postGift.handler(({ input, context }) => { const actorId = getUserId(context); return mapErrors( { - NOT_FOUND: [CommandDisabledError, ChatPlayerNotFoundError], + NOT_FOUND: [CommandDisabledError], CONFLICT: [ InsufficientBalanceError, ExceedsLimitError, BelowMinimumError, - NoOnlineUsersError, - RainCreditError, - DonateSelfError, - TooManyRecipientsError, - SelfModerationActionError, ChatCommandIdempotencyKeyReuseError, ConcurrentCommandReplayError, ], FORBIDDEN: [ChatRoomNotMemberError], }, - () => svc.executeCommand(input, actorId), - ); - }), - - getGift: os.getGift.handler(({ input, context }) => { - const viewerId = getUserId(context); - return mapErrors( - { NOT_FOUND: [GiftNotFoundError], FORBIDDEN: [ChatRoomNotMemberError] }, - () => svc.getGift(input.id, viewerId), + () => svc.postGift(input, actorId), ); }), @@ -67,33 +62,47 @@ export function createChatCommandsRouter(svc: ChatCommandsService, adminGuard: A { NOT_FOUND: [GiftNotFoundError], FORBIDDEN: [ChatRoomNotMemberError], - CONFLICT: [GiftAlreadyClaimedError, GiftSelfClaimError], + CONFLICT: [GiftAlreadyClaimedError, GiftSelfClaimError, GiftCreditError], }, () => svc.claimGift(input.id, claimerId), ); }), - mentionSearch: os.mentionSearch.handler(({ input, context }) => { - const viewerId = getUserId(context); - return svc.searchMentions(input.q, input.limit, viewerId); - }), - - playerSearch: os.playerSearch.handler(({ input, context }) => { + getGift: os.getGift.handler(({ input, context }) => { const viewerId = getUserId(context); - return svc.searchPlayers(input.q, input.limit, viewerId); + return mapErrors( + { + NOT_FOUND: [GiftNotFoundError], + FORBIDDEN: [ChatRoomNotMemberError], + }, + () => svc.getGift(input.id, viewerId), + ); }), - playerProfile: os.playerProfile.handler(({ input, context }) => { - const viewerId = getUserId(context); - return mapErrors({ NOT_FOUND: [ChatPlayerNotFoundError] }, () => - svc.getPlayerProfile(input.userId, viewerId), + postRain: os.postRain.handler(({ input, context }) => { + const actorId = getUserId(context); + return mapErrors( + { + NOT_FOUND: [CommandDisabledError], + CONFLICT: [ + InsufficientBalanceError, + ExceedsLimitError, + BelowMinimumError, + NoOnlineUsersError, + TooManyRecipientsError, + RainCreditError, + ChatCommandIdempotencyKeyReuseError, + ConcurrentCommandReplayError, + ], + FORBIDDEN: [ChatRoomNotMemberError], + }, + () => svc.postRain(input, actorId), ); }), - adminUpdateCommand: os.adminUpdateCommand.handler(async ({ input, context }) => { - await adminGuard.assert(context); - const actorId = getUserId(context); - return svc.adminUpdateCommand(input, actorId); + mentionSearch: os.mentionSearch.handler(({ input, context }) => { + const viewerId = getUserId(context); + return svc.searchMentions(input.q, input.limit, viewerId); }), }); } diff --git a/packages/core/src/engagement/chat-commands/seed/index.ts b/packages/core/src/engagement/chat-commands/seed/index.ts index 5606eb02..21888709 100644 --- a/packages/core/src/engagement/chat-commands/seed/index.ts +++ b/packages/core/src/engagement/chat-commands/seed/index.ts @@ -44,12 +44,24 @@ export async function seedChatCommands(db: DrizzleDb): Promise { label: 'Block', description: 'Block a player — their messages will be hidden from you', }, + { + key: 'unblock', + enabled: true, + label: 'Unblock', + description: 'Unblock a player — their messages will be visible to you again', + }, { key: 'ignore', enabled: true, label: 'Ignore', description: 'Ignore a player — their messages will be hidden from you', }, + { + key: 'unignore', + enabled: true, + label: 'Unignore', + description: 'Unignore a player — their messages will be visible to you again', + }, ]) .onConflictDoNothing(); } 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 95235f2d..bdd47870 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,33 +1,26 @@ import { describe, it, expect, vi } from 'vitest'; -import { mock, makeDrizzle, makeEventBus } from '../../../../testing/mock.js'; +import { mock, makeDrizzle } from '../../../../testing/mock.js'; import type { ChatSystemMessage, - ChatSystemWriter, ChatBlockWriter, - WalletCommands, AdminUserDirectory, - AdminGameReporting, - AuditWritePort, + GiftCommands, + SendGiftResult, + ClaimGiftResult, + GetGiftResult, + RainCommands, + SendRainResult, RealtimeTransport, - ChatRoomAccess, - CacheAdapter, + AuditWritePort, } from '@openora/core/contracts'; import { ChatCommandsService, CommandDisabledError, InsufficientBalanceError, - BelowMinimumError, NoOnlineUsersError, - ExceedsLimitError, GiftNotFoundError, GiftAlreadyClaimedError, - GiftSelfClaimError, - DonateSelfError, - ChatPlayerNotFoundError, - TooManyRecipientsError, - SelfModerationActionError, - ChatCommandIdempotencyKeyReuseError, - fingerprintCommand, + ChatRoomNotMemberError, } from '../chat-commands.service.js'; const ACTOR_ID = '00000000-0000-0000-0000-000000000001'; @@ -35,7 +28,7 @@ const CLAIMER_ID = '00000000-0000-0000-0000-000000000002'; const ROOM_ID = '00000000-0000-0000-0000-000000000003'; const MSG_ID = '00000000-0000-0000-0000-000000000004'; const GIFT_ID = '00000000-0000-0000-0000-000000000005'; -const OTHER_ROOM_ID = '00000000-0000-0000-0000-000000000009'; +const IDEMPOTENCY_KEY = '00000000-0000-0000-0000-0000000000aa'; const ENABLED_ROW = { key: 'gift', @@ -64,77 +57,63 @@ const SYSTEM_MSG: ChatSystemMessage = { createdAt: new Date().toISOString(), }; -/** Gift row as returned from the DB (dates as Date objects, amount as string). */ -const GIFT_ROW = { +function makeDirectory(): AdminUserDirectory { + return mock({ + findPlayerIds: vi.fn().mockResolvedValue([ACTOR_ID]), + lookupPlayers: vi + .fn() + .mockImplementation((ids: string[]) => + Promise.resolve( + ids.filter((id) => id === ACTOR_ID).map((id) => ({ userId: id, username: 'bob' })), + ), + ), + }); +} + +function makeBlockWriter(excluded: string[] = []): ChatBlockWriter { + return mock({ + blockUser: vi.fn().mockResolvedValue(undefined), + ignoreUser: vi.fn().mockResolvedValue(undefined), + getExcludedUserIds: vi.fn().mockResolvedValue(excluded), + }); +} + +const GIFT_STATE = { id: GIFT_ID, - messageId: MSG_ID, senderId: ACTOR_ID, senderUsername: 'bob', amount: '10.00000000', currency: 'USD', - roomId: ROOM_ID, claimedBy: null, claimedByUsername: null, claimedAt: null, - createdAt: new Date(), + createdAt: new Date().toISOString(), }; -function makeWriter(): ChatSystemWriter { - return mock({ - postSystemMessage: vi.fn().mockResolvedValue(SYSTEM_MSG), +function makeGiftCommands(overrides: Partial = {}): GiftCommands { + return mock({ + sendGift: vi.fn().mockResolvedValue({ ok: true, message: SYSTEM_MSG } satisfies SendGiftResult), + claimGift: vi.fn().mockResolvedValue({ + ok: true, + claimedBy: CLAIMER_ID, + claimedByUsername: 'alice', + claimedAt: new Date().toISOString(), + } satisfies ClaimGiftResult), + getGift: vi.fn().mockResolvedValue({ ok: true, gift: GIFT_STATE } satisfies GetGiftResult), + ...overrides, }); } -function makeWallet(ok = true): WalletCommands { - return mock({ - debit: vi - .fn() - .mockResolvedValue( - ok - ? { ok: true, newBalance: '90.00000000', currency: 'USD' } - : { ok: false, available: '5.00000000' }, - ), - credit: vi.fn().mockResolvedValue({ ok: true, newBalance: '110.00000000' }), +function makeRainCommands(overrides: Partial = {}): RainCommands { + return mock({ + sendRain: vi.fn().mockResolvedValue({ ok: true, message: SYSTEM_MSG } satisfies SendRainResult), + ...overrides, }); } -const DIRECTORY_CREATED_AT = new Date('2026-01-01T00:00:00.000Z'); - -function makeDirectory(senderUsername = 'bob', claimerUsername = 'alice'): AdminUserDirectory { - const all = [ - { - userId: ACTOR_ID, - username: senderUsername, - email: 'bob@example.com', - kycStatus: null, - language: null, - avatarUrl: null, - createdAt: DIRECTORY_CREATED_AT, - level: 3, - currency: 'USD', - }, - { - userId: CLAIMER_ID, - username: claimerUsername, - email: 'alice@example.com', - kycStatus: null, - language: null, - avatarUrl: null, - createdAt: DIRECTORY_CREATED_AT, - level: 1, - currency: 'USD', - }, - ]; - return mock({ - findPlayerIds: vi.fn().mockResolvedValue([ACTOR_ID]), - lookupPlayers: vi.fn().mockImplementation((ids: string[]) => { - return Promise.resolve(all.filter((p) => ids.includes(p.userId))); - }), - getPlayerByUsername: vi.fn().mockImplementation((username: string) => { - return Promise.resolve( - all.find((p) => p.username.toLowerCase() === username.toLowerCase()) ?? null, - ); - }), +function makeTransport(onlineIds: string[] = [CLAIMER_ID]): RealtimeTransport { + return mock({ + getOnlineUserIds: vi.fn().mockResolvedValue(onlineIds), }); } @@ -145,107 +124,45 @@ function makeAudit(): AuditWritePort { }); } -function makeBlockWriter(): ChatBlockWriter { - return mock({ - blockUser: vi.fn().mockResolvedValue(undefined), - ignoreUser: vi.fn().mockResolvedValue(undefined), - getExcludedUserIds: vi.fn().mockResolvedValue([]), - }); -} - -function makeGameReporting(): AdminGameReporting { - return mock({ - getPlayerStats: vi.fn().mockResolvedValue({ totalWagered: '100.00000000', totalBets: 5 }), - }); -} - -function makeTransport(onlineIds: string[] = [CLAIMER_ID]): RealtimeTransport { - return mock({ - getOnlineUserIds: vi.fn().mockResolvedValue(onlineIds), - publish: vi.fn().mockResolvedValue(undefined), - }); -} - -function makeRoomAccess(): ChatRoomAccess { - return mock({ verifyRoomAccess: vi.fn().mockResolvedValue(undefined) }); -} - -function makeCache(initial: Record = {}): CacheAdapter { - const values = new Map(Object.entries(initial)); - return { - get: async (key: string): Promise => values.get(key) as T | undefined, - set: vi.fn(async (key: string, value: unknown) => { - values.set(key, value); - }), - setIfAbsent: vi.fn(async (key: string, value: unknown) => { - if (values.has(key)) { - return false; - } - values.set(key, value); - return true; - }), - delete: vi.fn(async (key: string | string[]) => { - for (const item of Array.isArray(key) ? key : [key]) { - values.delete(item); - } - }), - }; -} - function makeSvc( overrides: { drizzleRows?: { select?: Record[][]; returning?: Record[][]; - execute?: Record[][]; }; - writer?: ChatSystemWriter; - wallet?: WalletCommands; directory?: AdminUserDirectory; + blockWriter?: ChatBlockWriter; + giftCommands?: GiftCommands; + rainCommands?: RainCommands; transport?: RealtimeTransport; audit?: AuditWritePort; - gameReporting?: AdminGameReporting; - blockWriter?: ChatBlockWriter; - roomAccess?: ChatRoomAccess; - cache?: CacheAdapter; } = {}, ) { const drizzle = makeDrizzle({ select: overrides.drizzleRows?.select ?? [], returning: overrides.drizzleRows?.returning ?? [], - execute: overrides.drizzleRows?.execute, }); return new ChatCommandsService( drizzle, - overrides.writer ?? makeWriter(), - overrides.wallet ?? makeWallet(), overrides.directory ?? makeDirectory(), - overrides.audit ?? makeAudit(), - overrides.transport ?? makeTransport(), - mock(makeEventBus()), overrides.blockWriter ?? makeBlockWriter(), - overrides.gameReporting ?? makeGameReporting(), - overrides.roomAccess ?? makeRoomAccess(), - overrides.cache ?? makeCache(), + overrides.giftCommands ?? makeGiftCommands(), + overrides.rainCommands ?? makeRainCommands(), + overrides.transport ?? makeTransport(), + overrides.audit ?? makeAudit(), ); } describe('ChatCommandsService.listCommands', () => { it('returns only enabled commands by default', async () => { - // mock simulates DB returning only enabled rows (WHERE enabled = true applied at SQL level) - const svc = makeSvc({ - drizzleRows: { select: [[ENABLED_ROW]] }, - }); + const svc = makeSvc({ drizzleRows: { select: [[ENABLED_ROW]] } }); const result = await svc.listCommands(); expect(result).toHaveLength(1); expect(result[0]?.key).toBe('gift'); }); it('returns all commands when includeDisabled is true', async () => { - // mock simulates DB returning all rows (no WHERE clause) - const svc = makeSvc({ - drizzleRows: { select: [[ENABLED_ROW, DISABLED_ROW]] }, - }); + const svc = makeSvc({ drizzleRows: { select: [[ENABLED_ROW, DISABLED_ROW]] } }); const result = await svc.listCommands(true); expect(result).toHaveLength(2); }); @@ -271,11 +188,7 @@ describe('ChatCommandsService.searchMentions', () => { it('excludes ids the viewer has blocked or ignored', async () => { const directory = makeDirectory(); - const blockWriter = mock({ - blockUser: vi.fn().mockResolvedValue(undefined), - ignoreUser: vi.fn().mockResolvedValue(undefined), - getExcludedUserIds: vi.fn().mockResolvedValue([ACTOR_ID]), - }); + const blockWriter = makeBlockWriter([ACTOR_ID]); const svc = makeSvc({ directory, blockWriter }); const result = await svc.searchMentions('bo', 5, CLAIMER_ID); expect(blockWriter.getExcludedUserIds).toHaveBeenCalledWith(CLAIMER_ID); @@ -283,465 +196,169 @@ describe('ChatCommandsService.searchMentions', () => { }); }); -describe('ChatCommandsService.searchPlayers', () => { - it('returns mapped player search results', async () => { - const directory = makeDirectory(); - const svc = makeSvc({ directory }); - const result = await svc.searchPlayers('bo', 5, CLAIMER_ID); - expect(directory.findPlayerIds).toHaveBeenCalledWith('bo', 5); - expect(result).toEqual([{ userId: ACTOR_ID, username: 'bob', avatarUrl: null, level: 3 }]); - }); +describe('ChatCommandsService.postGift (delegates to GIFT_COMMANDS)', () => { + it('returns the message from the port on success', async () => { + const giftCommands = makeGiftCommands(); + const svc = makeSvc({ giftCommands }); - it('returns empty array when no matches', async () => { - const directory = mock({ - findPlayerIds: vi.fn().mockResolvedValue([]), - lookupPlayers: vi.fn().mockResolvedValue([]), - }); - const svc = makeSvc({ directory }); - const result = await svc.searchPlayers('xyz', 10, CLAIMER_ID); - expect(result).toEqual([]); - }); - - it('excludes ids the viewer has blocked or ignored', async () => { - const directory = makeDirectory(); - const blockWriter = mock({ - blockUser: vi.fn().mockResolvedValue(undefined), - ignoreUser: vi.fn().mockResolvedValue(undefined), - getExcludedUserIds: vi.fn().mockResolvedValue([ACTOR_ID]), - }); - const svc = makeSvc({ directory, blockWriter }); - const result = await svc.searchPlayers('bo', 5, CLAIMER_ID); - expect(blockWriter.getExcludedUserIds).toHaveBeenCalledWith(CLAIMER_ID); - expect(result).toEqual([]); - }); -}); - -describe('ChatCommandsService.getPlayerProfile', () => { - it('returns the full profile card on the happy path', async () => { - const gameReporting = makeGameReporting(); - const svc = makeSvc({ directory: makeDirectory(), gameReporting }); - const result = await svc.getPlayerProfile(ACTOR_ID, ACTOR_ID); - expect(gameReporting.getPlayerStats).toHaveBeenCalledWith(ACTOR_ID); - expect(result).toEqual({ - userId: ACTOR_ID, - username: 'bob', - avatarUrl: null, - level: 3, - joinedAt: DIRECTORY_CREATED_AT.toISOString(), - totalWagered: '100.00000000', - totalBets: 5, - currency: 'USD', - }); - }); + const result = await svc.postGift( + { amount: '10.00000000', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, + ACTOR_ID, + ); - it('throws ChatPlayerNotFoundError for an unknown userId', async () => { - const directory = mock({ - lookupPlayers: vi.fn().mockResolvedValue([]), - }); - const svc = makeSvc({ directory }); - await expect(svc.getPlayerProfile(CLAIMER_ID, ACTOR_ID)).rejects.toThrow( - ChatPlayerNotFoundError, + expect(giftCommands.sendGift).toHaveBeenCalledWith( + { amount: '10.00000000', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, + ACTOR_ID, ); + expect(result).toEqual(SYSTEM_MSG); }); - it('redacts private profile fields for another viewer', async () => { - const gameReporting = makeGameReporting(); - const svc = makeSvc({ directory: makeDirectory(), gameReporting }); - const result = await svc.getPlayerProfile(ACTOR_ID, CLAIMER_ID); - expect(result).toMatchObject({ - userId: ACTOR_ID, - username: 'bob', - avatarUrl: null, - level: 3, - joinedAt: null, - totalWagered: null, - totalBets: null, - currency: null, + it('maps a "disabled" port result to CommandDisabledError', async () => { + const giftCommands = makeGiftCommands({ + sendGift: vi + .fn() + .mockResolvedValue({ ok: false, reason: 'disabled' } satisfies SendGiftResult), }); - expect(gameReporting.getPlayerStats).not.toHaveBeenCalled(); - }); -}); - -const IDEMPOTENCY_KEY = '00000000-0000-0000-0000-0000000000aa'; -const IDEMPOTENCY_CACHE_KEY = `chat-command:idempotency:${ACTOR_ID}:gift:${IDEMPOTENCY_KEY}`; -const RAIN_IDEMPOTENCY_CACHE_KEY = `chat-command:idempotency:${ACTOR_ID}:rain:${IDEMPOTENCY_KEY}`; -const IDEMPOTENCY_ROW_ID = '00000000-0000-0000-0000-0000000000bb'; + const svc = makeSvc({ giftCommands }); -describe('ChatCommandsService.executeCommand (gift)', () => { - it('throws CommandDisabledError when command row is missing', async () => { - const svc = makeSvc({ drizzleRows: { select: [[]] } }); await expect( - svc.executeCommand( - { type: 'gift', amount: '10', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, + svc.postGift( + { amount: '10.00000000', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, ACTOR_ID, ), ).rejects.toThrow(CommandDisabledError); }); - it('throws CommandDisabledError when command is disabled', async () => { - const svc = makeSvc({ drizzleRows: { select: [[DISABLED_ROW]] } }); - await expect( - svc.executeCommand( - { type: 'gift', amount: '10', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, - ACTOR_ID, - ), - ).rejects.toThrow(CommandDisabledError); - }); - - it('throws InsufficientBalanceError when wallet debit fails', async () => { - const svc = makeSvc({ - drizzleRows: { - select: [[ENABLED_ROW]], - // Only the idempotency guard insert is reached; wallet fails before the gift insert. - returning: [[{ id: IDEMPOTENCY_ROW_ID }]], - }, - wallet: makeWallet(false), + it('maps an "insufficient_balance" port result to InsufficientBalanceError', async () => { + const giftCommands = makeGiftCommands({ + sendGift: vi + .fn() + .mockResolvedValue({ ok: false, reason: 'insufficient_balance' } satisfies SendGiftResult), }); + const svc = makeSvc({ giftCommands }); + await expect( - svc.executeCommand( - { type: 'gift', amount: '10', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, + svc.postGift( + { amount: '10.00000000', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, ACTOR_ID, ), ).rejects.toThrow(InsufficientBalanceError); }); - it('posts system message with new gift metadata on success', async () => { - const writer = makeWriter(); - const svc = makeSvc({ - drizzleRows: { - select: [[ENABLED_ROW]], - // First returning: idempotency guard insert; second: chatGift insert. - returning: [[{ ...GIFT_ROW }]], - }, - writer, + it('maps a "room_not_member" port result to ChatRoomNotMemberError', async () => { + const giftCommands = makeGiftCommands({ + sendGift: vi + .fn() + .mockResolvedValue({ ok: false, reason: 'room_not_member' } satisfies SendGiftResult), }); - const result = await svc.executeCommand( - { type: 'gift', amount: '10.00000000', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, - ACTOR_ID, - ); - expect(writer.postSystemMessage).toHaveBeenCalledOnce(); - expect(writer.postSystemMessage).toHaveBeenCalledWith( - expect.objectContaining({ - metadata: expect.objectContaining({ - command: 'gift', - giftId: GIFT_ID, - senderId: ACTOR_ID, - senderUsername: 'bob', - }), - }), - ); - expect(result.id).toBe(MSG_ID); - }); + const svc = makeSvc({ giftCommands }); - it('enforces minAmount from config', async () => { - const svc = makeSvc({ - drizzleRows: { - select: [[{ ...ENABLED_ROW, config: { minAmount: '5.00000000' } }]], - }, - }); await expect( - svc.executeCommand( - { - type: 'gift', - amount: '1.00000000', - roomId: ROOM_ID, - idempotencyKey: IDEMPOTENCY_KEY, - }, + svc.postGift( + { amount: '10.00000000', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, ACTOR_ID, ), - ).rejects.toThrow(BelowMinimumError); + ).rejects.toThrow(ChatRoomNotMemberError); }); }); -describe('ChatCommandsService.executeCommand (gift idempotency)', () => { - it('inserts the idempotency guard row and debits once when a fresh key is supplied', async () => { - const wallet = makeWallet(); - const svc = makeSvc({ - drizzleRows: { - select: [[ENABLED_ROW], []], - returning: [[{ id: IDEMPOTENCY_ROW_ID }], [{ ...GIFT_ROW }]], - }, - wallet, - }); - const result = await svc.executeCommand( - { type: 'gift', amount: '10.00000000', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, - ACTOR_ID, - ); - expect(wallet.debit).toHaveBeenCalledOnce(); - expect(result.id).toBe(MSG_ID); - }); - - it('replays the stored result without debiting the wallet again', async () => { - const wallet = makeWallet(); - const giftInput = { - type: 'gift' as const, - amount: '10.00000000', - roomId: ROOM_ID, - idempotencyKey: IDEMPOTENCY_KEY, - }; - const storedRow = { - id: IDEMPOTENCY_ROW_ID, - actorId: ACTOR_ID, - commandType: 'gift', - idempotencyKey: IDEMPOTENCY_KEY, - amount: '10.00000000', - fingerprint: fingerprintCommand(giftInput), - result: SYSTEM_MSG, - createdAt: new Date(), - }; - const svc = makeSvc({ - drizzleRows: { select: [[ENABLED_ROW]] }, - cache: makeCache({ - [IDEMPOTENCY_CACHE_KEY]: { fingerprint: storedRow.fingerprint, result: SYSTEM_MSG }, - }), - wallet, - }); - const result = await svc.executeCommand(giftInput, ACTOR_ID); - expect(wallet.debit).not.toHaveBeenCalled(); - expect(result).toEqual(SYSTEM_MSG); - }); - - it('throws ChatCommandIdempotencyKeyReuseError when the same key is reused with a different amount', async () => { - const storedRow = { - id: IDEMPOTENCY_ROW_ID, - actorId: ACTOR_ID, - commandType: 'gift', - idempotencyKey: IDEMPOTENCY_KEY, - amount: '5.00000000', - fingerprint: fingerprintCommand({ - type: 'gift', - amount: '5.00000000', - roomId: ROOM_ID, - idempotencyKey: IDEMPOTENCY_KEY, - }), - result: SYSTEM_MSG, - createdAt: new Date(), - }; - const svc = makeSvc({ - drizzleRows: { select: [[ENABLED_ROW]] }, - cache: makeCache({ - [IDEMPOTENCY_CACHE_KEY]: { fingerprint: storedRow.fingerprint, result: SYSTEM_MSG }, - }), - }); - await expect( - svc.executeCommand( - { type: 'gift', amount: '10.00000000', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, - ACTOR_ID, - ), - ).rejects.toThrow(ChatCommandIdempotencyKeyReuseError); - }); - - it('throws ChatCommandIdempotencyKeyReuseError when the same key+amount is reused for a different room', async () => { - const storedRow = { - id: IDEMPOTENCY_ROW_ID, - actorId: ACTOR_ID, - commandType: 'gift', - idempotencyKey: IDEMPOTENCY_KEY, - amount: '10.00000000', - fingerprint: fingerprintCommand({ - type: 'gift', - amount: '10.00000000', - roomId: OTHER_ROOM_ID, - idempotencyKey: IDEMPOTENCY_KEY, - }), - result: SYSTEM_MSG, - createdAt: new Date(), - }; - const svc = makeSvc({ - drizzleRows: { select: [[ENABLED_ROW]] }, - cache: makeCache({ - [IDEMPOTENCY_CACHE_KEY]: { fingerprint: storedRow.fingerprint, result: SYSTEM_MSG }, - }), - }); - await expect( - svc.executeCommand( - { type: 'gift', amount: '10.00000000', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, - ACTOR_ID, - ), - ).rejects.toThrow(ChatCommandIdempotencyKeyReuseError); - }); -}); +describe('ChatCommandsService.claimGift (delegates to GIFT_COMMANDS)', () => { + it('returns the claim info from the port on success', async () => { + const giftCommands = makeGiftCommands(); + const svc = makeSvc({ giftCommands }); -describe('ChatCommandsService.claimGift', () => { - it('credits the claimer and returns claim info on happy path', async () => { - const wallet = makeWallet(); - const svc = makeSvc({ - drizzleRows: { - // select[0]: gift lookup; returning[0]: atomic update result - select: [[GIFT_ROW]], - returning: [ - [ - { - ...GIFT_ROW, - claimedBy: CLAIMER_ID, - claimedByUsername: 'alice', - claimedAt: new Date(), - }, - ], - ], - }, - wallet, - }); const result = await svc.claimGift(GIFT_ID, CLAIMER_ID); - expect(wallet.credit).toHaveBeenCalledOnce(); - expect(wallet.credit).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ userId: CLAIMER_ID, type: 'gift' }), - ); + + expect(giftCommands.claimGift).toHaveBeenCalledWith(GIFT_ID, CLAIMER_ID); expect(result.claimedBy).toBe(CLAIMER_ID); expect(result.claimedByUsername).toBe('alice'); - expect(result.claimedAt).toEqual(expect.any(String)); }); - it('throws GiftSelfClaimError when sender tries to claim their own gift', async () => { - const svc = makeSvc({ - drizzleRows: { - select: [[GIFT_ROW]], // senderId === ACTOR_ID - returning: [], - }, + it('maps a "gift_not_found" port result to GiftNotFoundError', async () => { + const giftCommands = makeGiftCommands({ + claimGift: vi + .fn() + .mockResolvedValue({ ok: false, reason: 'gift_not_found' } satisfies ClaimGiftResult), }); - await expect(svc.claimGift(GIFT_ID, ACTOR_ID)).rejects.toThrow(GiftSelfClaimError); - }); + const svc = makeSvc({ giftCommands }); - it('throws GiftAlreadyClaimedError when update returns no rows (race lost)', async () => { - const svc = makeSvc({ - drizzleRows: { - select: [[GIFT_ROW]], - returning: [[]], // empty = already claimed - }, - }); - await expect(svc.claimGift(GIFT_ID, CLAIMER_ID)).rejects.toThrow(GiftAlreadyClaimedError); + await expect(svc.claimGift(GIFT_ID, CLAIMER_ID)).rejects.toThrow(GiftNotFoundError); }); - it('throws GiftNotFoundError when gift does not exist', async () => { - const svc = makeSvc({ - drizzleRows: { - select: [[]], // no gift row - returning: [], - }, + it('maps an "already_claimed" port result to GiftAlreadyClaimedError', async () => { + const giftCommands = makeGiftCommands({ + claimGift: vi + .fn() + .mockResolvedValue({ ok: false, reason: 'already_claimed' } satisfies ClaimGiftResult), }); - await expect(svc.claimGift(GIFT_ID, CLAIMER_ID)).rejects.toThrow(GiftNotFoundError); + const svc = makeSvc({ giftCommands }); + + await expect(svc.claimGift(GIFT_ID, CLAIMER_ID)).rejects.toThrow(GiftAlreadyClaimedError); }); -}); -describe('ChatCommandsService.adminUpdateCommand', () => { - it('upserts the command row and returns the descriptor', async () => { - const updatedRow = { ...ENABLED_ROW, key: 'gift', enabled: false }; - const svc = makeSvc({ - drizzleRows: { returning: [[updatedRow]] }, + it('maps a "room_not_member" port result to ChatRoomNotMemberError using the port\'s roomId, not the giftId', async () => { + const giftCommands = makeGiftCommands({ + claimGift: vi.fn().mockResolvedValue({ + ok: false, + reason: 'room_not_member', + roomId: ROOM_ID, + } satisfies ClaimGiftResult), }); - const result = await svc.adminUpdateCommand({ key: 'gift', enabled: false }, ACTOR_ID); - expect(result.key).toBe('gift'); - expect(result.enabled).toBe(false); - }); + const svc = makeSvc({ giftCommands }); - it('records an audit entry on update', async () => { - const updatedRow = { ...ENABLED_ROW, key: 'rain', enabled: false }; - const audit = makeAudit(); - const drizzle = makeDrizzle({ select: [], returning: [[updatedRow]] }); - const svcWithAudit = new ChatCommandsService( - drizzle, - makeWriter(), - makeWallet(), - makeDirectory(), - audit, - makeTransport(), - mock(makeEventBus()), - makeBlockWriter(), - makeGameReporting(), - makeRoomAccess(), - makeCache(), - ); - await svcWithAudit.adminUpdateCommand({ key: 'rain', enabled: false }, ACTOR_ID); - expect(audit.record).toHaveBeenCalledWith( - expect.objectContaining({ - actorId: ACTOR_ID, - actorType: 'admin', - action: 'chat.command.updated', - resourceId: 'rain', - }), + await expect(svc.claimGift(GIFT_ID, CLAIMER_ID)).rejects.toThrow( + new ChatRoomNotMemberError(ROOM_ID), ); }); }); -describe('ChatCommandsService.handleRain', () => { - const RAIN_ROW = { ...ENABLED_ROW, key: 'rain', label: 'Rain' }; +describe('ChatCommandsService.getGift (delegates to GIFT_COMMANDS)', () => { + it('returns the gift state from the port on success', async () => { + const giftCommands = makeGiftCommands(); + const svc = makeSvc({ giftCommands }); - it('throws NoOnlineUsersError when no other users are online', async () => { - const svc = makeSvc({ - drizzleRows: { select: [[RAIN_ROW]] }, - transport: makeTransport([ACTOR_ID]), + const result = await svc.getGift(GIFT_ID, CLAIMER_ID); + + expect(giftCommands.getGift).toHaveBeenCalledWith(GIFT_ID, CLAIMER_ID); + expect(result).toEqual(GIFT_STATE); + }); + + it('maps a "gift_not_found" port result to GiftNotFoundError', async () => { + const giftCommands = makeGiftCommands({ + getGift: vi + .fn() + .mockResolvedValue({ ok: false, reason: 'gift_not_found' } satisfies GetGiftResult), }); - await expect( - svc.executeCommand( - { - type: 'rain', - amount: '10.00000000', - recipientCount: 5, - roomId: ROOM_ID, - idempotencyKey: IDEMPOTENCY_KEY, - }, - ACTOR_ID, - ), - ).rejects.toThrow(NoOnlineUsersError); + const svc = makeSvc({ giftCommands }); + + await expect(svc.getGift(GIFT_ID, CLAIMER_ID)).rejects.toThrow(GiftNotFoundError); }); - it('replays before discovering online presence', async () => { - const transport = makeTransport(); - const storedRow = { - id: IDEMPOTENCY_ROW_ID, - actorId: ACTOR_ID, - commandType: 'rain', - idempotencyKey: IDEMPOTENCY_KEY, - amount: '10.99000000', - fingerprint: fingerprintCommand({ - type: 'rain', - amount: '10.99000000', - recipientCount: 10, + it('maps a "room_not_member" port result to ChatRoomNotMemberError using the port\'s roomId', async () => { + const giftCommands = makeGiftCommands({ + getGift: vi.fn().mockResolvedValue({ + ok: false, + reason: 'room_not_member', roomId: ROOM_ID, - idempotencyKey: IDEMPOTENCY_KEY, - }), - result: SYSTEM_MSG, - createdAt: new Date(), - }; - const svc = makeSvc({ - drizzleRows: { select: [[RAIN_ROW]] }, - transport, - cache: makeCache({ - [RAIN_IDEMPOTENCY_CACHE_KEY]: { fingerprint: storedRow.fingerprint, result: SYSTEM_MSG }, - }), + } satisfies GetGiftResult), }); + const svc = makeSvc({ giftCommands }); - await expect( - svc.executeCommand( - { - type: 'rain', - amount: '10.99000000', - recipientCount: 10, - roomId: ROOM_ID, - idempotencyKey: IDEMPOTENCY_KEY, - }, - ACTOR_ID, - ), - ).resolves.toEqual(SYSTEM_MSG); - expect(transport.getOnlineUserIds).not.toHaveBeenCalled(); + await expect(svc.getGift(GIFT_ID, CLAIMER_ID)).rejects.toThrow( + new ChatRoomNotMemberError(ROOM_ID), + ); }); +}); - it('distributes to online recipients excluding the actor', async () => { +describe('ChatCommandsService.postRain (resolves presence, delegates to RAIN_COMMANDS)', () => { + it('resolves online user ids via CHAT_REALTIME_TRANSPORT and passes them to the port', async () => { const RECIPIENT_2 = '00000000-0000-0000-0000-000000000006'; - const wallet = makeWallet(); - const svc = makeSvc({ - drizzleRows: { - select: [[RAIN_ROW]], - returning: [[{ id: IDEMPOTENCY_ROW_ID }]], - execute: [[{ per_recipient: '5.00000000', total_distributed: '10.00000000' }]], - }, - wallet, - transport: makeTransport([ACTOR_ID, CLAIMER_ID, RECIPIENT_2]), - }); - await svc.executeCommand( + const transport = makeTransport([ACTOR_ID, CLAIMER_ID, RECIPIENT_2]); + const rainCommands = makeRainCommands(); + const svc = makeSvc({ transport, rainCommands }); + + const result = await svc.postRain( { - type: 'rain', amount: '10.00000000', recipientCount: 2, roomId: ROOM_ID, @@ -749,403 +366,151 @@ describe('ChatCommandsService.handleRain', () => { }, ACTOR_ID, ); - expect(wallet.credit).toHaveBeenCalledTimes(2); - expect(wallet.credit).toHaveBeenCalledWith(expect.anything(), { - userId: CLAIMER_ID, - amount: '5.00000000', - currency: 'USD', - type: 'rain', - }); - }); - it('uses Postgres floor division to avoid float imprecision', async () => { - const wallet = makeWallet(); - const svc = makeSvc({ - drizzleRows: { - select: [[RAIN_ROW]], - returning: [[{ id: IDEMPOTENCY_ROW_ID }]], - execute: [[{ per_recipient: '3.33333333', total_distributed: '9.99999999' }]], - }, - wallet, - transport: makeTransport([ - CLAIMER_ID, - '00000000-0000-0000-0000-000000000006', - '00000000-0000-0000-0000-000000000007', - ]), - }); - await svc.executeCommand( + expect(transport.getOnlineUserIds).toHaveBeenCalledWith(`chat:room:${ROOM_ID}`); + expect(rainCommands.sendRain).toHaveBeenCalledWith( { - type: 'rain', amount: '10.00000000', - recipientCount: 3, - roomId: ROOM_ID, - idempotencyKey: IDEMPOTENCY_KEY, - }, - ACTOR_ID, - ); - expect(wallet.credit).toHaveBeenCalledWith(expect.anything(), { - userId: expect.any(String), - amount: '3.33333333', - currency: 'USD', - type: 'rain', - }); - }); - - it('debits only the amount actually distributed (floor(amount/n)*n), never the raw player-typed amount', async () => { - const wallet = makeWallet(); - const svc = makeSvc({ - drizzleRows: { - select: [[RAIN_ROW]], - returning: [[{ id: IDEMPOTENCY_ROW_ID }]], - execute: [[{ per_recipient: '1.00000000', total_distributed: '10.00000000' }]], - }, - wallet, - transport: makeTransport( - Array.from({ length: 10 }, (_, i) => `00000000-0000-0000-0000-0000000000${10 + i}`), - ), - }); - await svc.executeCommand( - { - type: 'rain', - amount: '10.99000000', - recipientCount: 10, + recipientCount: 2, roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY, + onlineUserIds: [ACTOR_ID, CLAIMER_ID, RECIPIENT_2], }, ACTOR_ID, ); - expect(wallet.debit).toHaveBeenCalledWith(expect.anything(), { - userId: ACTOR_ID, - amount: '10.00000000', - type: 'rain', - }); + expect(result).toEqual(SYSTEM_MSG); }); - it('throws BelowMinimumError when amount is below config minAmount', async () => { - const svc = makeSvc({ - drizzleRows: { - select: [[{ ...RAIN_ROW, config: { minAmount: '5.00000000' } }]], - }, + it('maps a "no_online_users" port result to NoOnlineUsersError', async () => { + const rainCommands = makeRainCommands({ + sendRain: vi + .fn() + .mockResolvedValue({ ok: false, reason: 'no_online_users' } satisfies SendRainResult), }); - await expect( - svc.executeCommand( - { - type: 'rain', - amount: '1.00000000', - recipientCount: 1, - roomId: ROOM_ID, - idempotencyKey: IDEMPOTENCY_KEY, - }, - ACTOR_ID, - ), - ).rejects.toThrow(BelowMinimumError); - }); + const svc = makeSvc({ rainCommands, transport: makeTransport([ACTOR_ID]) }); - it('throws ExceedsLimitError when amount is above config maxAmount', async () => { - const svc = makeSvc({ - drizzleRows: { - select: [[{ ...RAIN_ROW, config: { maxAmount: '10.00000000' } }]], - }, - }); await expect( - svc.executeCommand( + svc.postRain( { - type: 'rain', - amount: '50.00000000', - recipientCount: 1, + amount: '10.00000000', + recipientCount: 2, roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY, }, ACTOR_ID, ), - ).rejects.toThrow(ExceedsLimitError); + ).rejects.toThrow(NoOnlineUsersError); }); - it('throws ExceedsLimitError when recipientCount exceeds config maxRecipients', async () => { - const svc = makeSvc({ - drizzleRows: { - select: [[{ ...RAIN_ROW, config: { maxRecipients: 10 } }]], - }, + it('maps a "disabled" port result to CommandDisabledError', async () => { + const rainCommands = makeRainCommands({ + sendRain: vi + .fn() + .mockResolvedValue({ ok: false, reason: 'disabled' } satisfies SendRainResult), }); - await expect( - svc.executeCommand( - { - type: 'rain', - amount: '100.00000000', - recipientCount: 11, - roomId: ROOM_ID, - idempotencyKey: IDEMPOTENCY_KEY, - }, - ACTOR_ID, - ), - ).rejects.toThrow(ExceedsLimitError); - }); + const svc = makeSvc({ rainCommands }); - it('throws TooManyRecipientsError when recipientCount exceeds the whole-dollar amount', async () => { - const svc = makeSvc({ - drizzleRows: { select: [[RAIN_ROW]] }, - }); await expect( - svc.executeCommand( + svc.postRain( { - type: 'rain', - amount: '3.00000000', - recipientCount: 4, + amount: '10.00000000', + recipientCount: 2, roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY, }, ACTOR_ID, ), - ).rejects.toThrow(TooManyRecipientsError); - }); -}); - -const DONATE_ROW = { - key: 'donate', - enabled: true, - label: 'Donate', - description: 'Send a direct tip to a specific player', - config: null, - updatedAt: new Date(), -}; - -const DONATE_SYSTEM_MSG: import('@openora/core/contracts').ChatSystemMessage = { - id: MSG_ID, - roomId: ROOM_ID, - actorId: ACTOR_ID, - content: '', - metadata: { - command: 'donate', - recipientId: CLAIMER_ID, - recipientUsername: 'alice', - amount: '10.00000000', - currency: 'USD', - }, - createdAt: new Date().toISOString(), -}; - -/** - * Directory mock where findPlayerIds resolves the RECIPIENT (CLAIMER_ID = 'alice'). - * Used for donate tests that target 'alice' — the default makeDirectory() always - * returns ACTOR_ID which only has username 'bob', so alice is never found. - */ -function makeRecipientDirectory(): import('@openora/core/contracts').AdminUserDirectory { - const all = [ - { - userId: ACTOR_ID, - username: 'bob', - email: 'bob@example.com', - kycStatus: null, - language: null, - avatarUrl: null, - createdAt: DIRECTORY_CREATED_AT, - level: 3, - currency: 'USD', - }, - { - userId: CLAIMER_ID, - username: 'alice', - email: 'alice@example.com', - kycStatus: null, - language: null, - avatarUrl: null, - createdAt: DIRECTORY_CREATED_AT, - level: 1, - currency: 'USD', - }, - ]; - return mock({ - findPlayerIds: vi.fn().mockResolvedValue([CLAIMER_ID]), - lookupPlayers: vi.fn().mockImplementation((ids: string[]) => { - return Promise.resolve(all.filter((p) => ids.includes(p.userId))); - }), - getPlayerByUsername: vi.fn().mockImplementation((username: string) => { - return Promise.resolve( - all.find((p) => p.username.toLowerCase() === username.toLowerCase()) ?? null, - ); - }), + ).rejects.toThrow(CommandDisabledError); }); -} -describe('ChatCommandsService.handleDonate', () => { - it('debits sender, credits recipient, and returns system message on success', async () => { - const wallet = makeWallet(); - const writer = mock({ - postSystemMessage: vi.fn().mockResolvedValue(DONATE_SYSTEM_MSG), + it('maps a "room_not_member" port result to ChatRoomNotMemberError', async () => { + const rainCommands = makeRainCommands({ + sendRain: vi + .fn() + .mockResolvedValue({ ok: false, reason: 'room_not_member' } satisfies SendRainResult), }); - const svc = makeSvc({ - drizzleRows: { - select: [[DONATE_ROW]], - returning: [[{ id: IDEMPOTENCY_ROW_ID }]], - }, - wallet, - writer, - directory: makeRecipientDirectory(), - }); - const result = await svc.executeCommand( - { - type: 'donate', - targetUsername: 'alice', - amount: '10.00000000', - roomId: ROOM_ID, - idempotencyKey: IDEMPOTENCY_KEY, - }, - ACTOR_ID, - ); - expect(wallet.debit).toHaveBeenCalledWith(expect.anything(), { - userId: ACTOR_ID, - amount: '10.00000000', - type: 'tip', - }); - expect(wallet.credit).toHaveBeenCalledWith(expect.anything(), { - userId: CLAIMER_ID, - amount: '10.00000000', - currency: 'USD', - type: 'tip', - }); - expect(result.id).toBe(MSG_ID); - }); + const svc = makeSvc({ rainCommands }); - it('throws DonateSelfError when sender targets themselves', async () => { - // Default makeDirectory() returns ACTOR_ID ('bob') from findPlayerIds — correct for self-target. - const svc = makeSvc({ - drizzleRows: { select: [[DONATE_ROW]] }, - }); await expect( - svc.executeCommand( + svc.postRain( { - type: 'donate', - targetUsername: 'bob', amount: '10.00000000', + recipientCount: 2, roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY, }, ACTOR_ID, ), - ).rejects.toThrow(DonateSelfError); + ).rejects.toThrow(ChatRoomNotMemberError); }); +}); - it('throws ChatPlayerNotFoundError when target username does not exist', async () => { - const directory = mock({ - findPlayerIds: vi.fn().mockResolvedValue([]), - lookupPlayers: vi.fn().mockResolvedValue([]), - getPlayerByUsername: vi.fn().mockResolvedValue(null), - }); +describe('ChatCommandsService.adminListCommands', () => { + it('returns disabled commands too, unlike listCommands', async () => { const svc = makeSvc({ - drizzleRows: { select: [[DONATE_ROW]] }, - directory, + drizzleRows: { select: [[ENABLED_ROW, DISABLED_ROW], [{ n: 2 }]] }, }); - await expect( - svc.executeCommand( - { - type: 'donate', - targetUsername: 'ghost', - amount: '10.00000000', - roomId: ROOM_ID, - idempotencyKey: IDEMPOTENCY_KEY, - }, - ACTOR_ID, - ), - ).rejects.toThrow(ChatPlayerNotFoundError); - }); - it('throws InsufficientBalanceError when sender wallet debit fails', async () => { - const svc = makeSvc({ - drizzleRows: { - select: [[DONATE_ROW]], - returning: [[{ id: IDEMPOTENCY_ROW_ID }]], - }, - wallet: makeWallet(false), - directory: makeRecipientDirectory(), + const result = await svc.adminListCommands({ + page: 1, + limit: 10, + sortBy: 'key', + sortOrder: 'asc', }); - await expect( - svc.executeCommand( - { - type: 'donate', - targetUsername: 'alice', - amount: '10.00000000', - roomId: ROOM_ID, - idempotencyKey: IDEMPOTENCY_KEY, - }, - ACTOR_ID, - ), - ).rejects.toThrow(InsufficientBalanceError); - }); - it('throws BelowMinimumError when amount is below config minAmount', async () => { - // Amount check fires before directory lookup, so directory mock does not matter here. - const svc = makeSvc({ - drizzleRows: { - select: [[{ ...DONATE_ROW, config: { minAmount: '5.00000000' } }]], - }, - }); - await expect( - svc.executeCommand( - { - type: 'donate', - targetUsername: 'alice', - amount: '1.00000000', - roomId: ROOM_ID, - idempotencyKey: IDEMPOTENCY_KEY, - }, - ACTOR_ID, - ), - ).rejects.toThrow(BelowMinimumError); + expect(result.items).toHaveLength(2); + expect(result.total).toBe(2); + expect(result.items.map((c) => c.enabled)).toEqual([true, false]); }); }); -const BLOCK_ROW = { ...ENABLED_ROW, key: 'block', label: 'Block' }; -const IGNORE_ROW = { ...ENABLED_ROW, key: 'ignore', label: 'Ignore' }; - -describe('ChatCommandsService.handleBlockAction', () => { - it('dispatches "block" to blockWriter.blockUser, not ignoreUser', async () => { - const blockWriter = makeBlockWriter(); - const svc = makeSvc({ - drizzleRows: { select: [[BLOCK_ROW]] }, - directory: makeRecipientDirectory(), - blockWriter, - }); +describe('ChatCommandsService.adminUpdateCommand', () => { + it('creates a new config row when none exists', async () => { + const row = { ...ENABLED_ROW, key: 'donate', label: 'Donate' }; + const svc = makeSvc({ drizzleRows: { returning: [[row]] } }); - await svc.executeCommand({ type: 'block', targetUsername: 'alice', roomId: ROOM_ID }, ACTOR_ID); + const result = await svc.adminUpdateCommand({ key: 'donate', enabled: true }, ACTOR_ID); - expect(blockWriter.blockUser).toHaveBeenCalledWith(ACTOR_ID, CLAIMER_ID); - expect(blockWriter.ignoreUser).not.toHaveBeenCalled(); + expect(result).toMatchObject({ key: 'donate', label: 'Donate', enabled: true }); }); - it('dispatches "ignore" to blockWriter.ignoreUser, not blockUser', async () => { - const blockWriter = makeBlockWriter(); - const svc = makeSvc({ - drizzleRows: { select: [[IGNORE_ROW]] }, - directory: makeRecipientDirectory(), - blockWriter, - }); + it('updates an existing config row', async () => { + const row = { ...ENABLED_ROW, enabled: false, config: { maxAmount: '100.00000000' } }; + const svc = makeSvc({ drizzleRows: { returning: [[row]] } }); - await svc.executeCommand( - { type: 'ignore', targetUsername: 'alice', roomId: ROOM_ID }, + const result = await svc.adminUpdateCommand( + { key: 'gift', enabled: false, config: { maxAmount: '100.00000000' } }, ACTOR_ID, ); - expect(blockWriter.ignoreUser).toHaveBeenCalledWith(ACTOR_ID, CLAIMER_ID); - expect(blockWriter.blockUser).not.toHaveBeenCalled(); + expect(result).toMatchObject({ key: 'gift', enabled: false }); }); - it('throws SelfModerationActionError on self-block', async () => { - const svc = makeSvc({ - drizzleRows: { select: [[BLOCK_ROW]] }, - }); + it('records an audit entry describing the change', async () => { + const audit = makeAudit(); + const svc = makeSvc({ audit, drizzleRows: { returning: [[ENABLED_ROW]] } }); - await expect( - svc.executeCommand({ type: 'block', targetUsername: 'bob', roomId: ROOM_ID }, ACTOR_ID), - ).rejects.toThrow(SelfModerationActionError); - }); + await svc.adminUpdateCommand( + { key: 'gift', enabled: true, config: { maxAmount: '50.00000000' } }, + ACTOR_ID, + ); - it('throws SelfModerationActionError on self-ignore', async () => { - const svc = makeSvc({ - drizzleRows: { select: [[IGNORE_ROW]] }, + expect(audit.record).toHaveBeenCalledWith({ + actorId: ACTOR_ID, + actorType: 'admin', + action: 'chat.command.updated', + resourceType: 'chat_command', + resourceId: 'gift', + before: null, + after: { enabled: true, config: { maxAmount: '50.00000000' } }, }); + }); - await expect( - svc.executeCommand({ type: 'ignore', targetUsername: 'bob', roomId: ROOM_ID }, ACTOR_ID), - ).rejects.toThrow(SelfModerationActionError); + it('throws CommandDisabledError when the insert/update returns no row', async () => { + const svc = makeSvc({ drizzleRows: { returning: [[]] } }); + + await expect(svc.adminUpdateCommand({ key: 'gift', enabled: true }, ACTOR_ID)).rejects.toThrow( + CommandDisabledError, + ); }); }); 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 b951b1de..46dae631 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 @@ -1,47 +1,38 @@ -import { createHash } from 'node:crypto'; -import { and, eq, isNull, sql } from 'drizzle-orm'; +import { eq, asc, desc, count } from 'drizzle-orm'; import { DrizzleService, makeNotFoundError, makeConflictError, - mapConcurrent, - moneyToNumber, - findOneOrThrow, - serializeRow, createDomainError, - type EventBus, + findOneOrThrow, + pageToOffset, } from '@openora/core/server'; import type { Uuid, ChatSystemMessage, - ChatSystemWriter, ChatBlockWriter, - WalletCommands, AdminUserDirectory, - AdminGameReporting, - AuditWritePort, + GiftCommands, + RainCommands, RealtimeTransport, - ChatRoomAccess, - CacheAdapter, + AuditWritePort, } from '@openora/core/contracts'; import { chatChannel } from '@openora/core/contracts'; +import type { SortOrder } from '@openora/core/contracts/kit'; import type { ChatCommandDescriptor, ChatCommandType, CommandConfig, - GiftState, + PostGiftInput, + PostRainInput, ClaimGiftOutput, - PlayerSearchResult, - PlayerProfileCard, + GiftState, + AdminCommandSortBy, } from '../contract/index.js'; import { ChatCommandTypeSchema } from '../contract/index.js'; -import { chatCommandConfig, chatGift } from '../schema/index.js'; +import { chatCommandConfig } from '../schema/index.js'; export const CommandDisabledError = makeNotFoundError('ChatCommand'); -export const NoOnlineUsersError = makeConflictError( - 'NoOnlineUsers', - 'No other users are online in this room', -); export const InsufficientBalanceError = makeConflictError( 'InsufficientBalance', 'Not enough balance', @@ -54,11 +45,18 @@ export const BelowMinimumError = makeConflictError( 'BelowMinimum', 'Amount is below the minimum for this command', ); +export const NoOnlineUsersError = makeConflictError( + 'NoOnlineUsers', + 'No other users are online in this room', +); +export const TooManyRecipientsError = makeConflictError( + 'TooManyRecipients', + 'Amount too small: you need at least $1 per recipient', +); export const RainCreditError = makeConflictError( 'RainCreditError', 'A recipient wallet is unavailable; rain aborted', ); -export const ChatPlayerNotFoundError = makeNotFoundError('ChatPlayer'); export const GiftNotFoundError = makeNotFoundError('ChatGift'); export const GiftAlreadyClaimedError = makeConflictError( 'GiftAlreadyClaimed', @@ -68,14 +66,9 @@ export const GiftSelfClaimError = makeConflictError( 'GiftSelfClaim', 'You cannot claim your own gift', ); -export const DonateSelfError = makeConflictError('DonateSelf', 'You cannot donate to yourself'); -export const SelfModerationActionError = makeConflictError( - 'SelfModerationAction', - 'You cannot block or ignore yourself', -); -export const TooManyRecipientsError = makeConflictError( - 'TooManyRecipients', - 'Amount too small: you need at least $1 per recipient', +export const GiftCreditError = makeConflictError( + 'GiftCreditError', + 'Recipient wallet is unavailable; gift claim aborted', ); export const ChatCommandIdempotencyKeyReuseError = makeConflictError( 'ChatCommandIdempotencyKeyReuse', @@ -87,76 +80,10 @@ export const ConcurrentCommandReplayError = makeConflictError( ); export const ChatRoomNotMemberError = createDomainError( 'ChatRoomNotMemberError', - (roomId: Uuid) => `You are not a member of room: ${roomId}`, + (roomId: Uuid | null) => + roomId ? `You are not a member of room: ${roomId}` : 'You are not a member of global chat', ); -type GiftInput = { type: 'gift'; amount: string; roomId: Uuid; idempotencyKey: Uuid }; -type RainInput = { - type: 'rain'; - amount: string; - recipientCount: number; - roomId: Uuid; - idempotencyKey: Uuid; -}; -type DonateInput = { - type: 'donate'; - targetUsername: string; - amount: string; - roomId: Uuid | null; - idempotencyKey: Uuid; -}; -type BlockActionInput = { - type: 'block' | 'ignore'; - targetUsername: string; - roomId: Uuid | null; -}; -type ExecuteInput = GiftInput | RainInput | DonateInput | BlockActionInput; -type MoneyMovingInput = GiftInput | RainInput | DonateInput; - -const COMMAND_IDEMPOTENCY_TTL_MS = 5 * 60 * 1000; -type CommandIdempotencyRecord = { - fingerprint: string; - result: ChatSystemMessage | null; -}; - -// The replay guard must match on the COMPLETE request, not just the amount - a reused -// key with a different room, recipient count, or donate target is a distinct request, -// not a replay of the original. `idempotencyKey` itself is excluded so the fingerprint -// is stable for the row it guards. -export function fingerprintCommand(input: MoneyMovingInput): string { - const canonical: Record = - input.type === 'gift' - ? { type: input.type, amount: input.amount, roomId: input.roomId } - : input.type === 'rain' - ? { - type: input.type, - amount: input.amount, - recipientCount: input.recipientCount, - roomId: input.roomId, - } - : { - type: input.type, - amount: input.amount, - targetUsername: input.targetUsername, - roomId: input.roomId, - }; - return createHash('sha256').update(JSON.stringify(canonical)).digest('hex'); -} - -function shuffleArray(arr: readonly T[]): T[] { - const result = [...arr]; - for (let i = result.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - const a = result.at(i); - const b = result.at(j); - if (a !== undefined && b !== undefined) { - result[i] = b; - result[j] = a; - } - } - return result; -} - function toDescriptor(row: typeof chatCommandConfig.$inferSelect): ChatCommandDescriptor { return { key: ChatCommandTypeSchema.parse(row.key), @@ -164,23 +91,24 @@ function toDescriptor(row: typeof chatCommandConfig.$inferSelect): ChatCommandDe label: row.label, description: row.description ?? null, config: row.config ?? null, + updatedAt: row.updatedAt.toISOString(), }; } +const ADMIN_COMMAND_SORT_COLUMNS = { + key: chatCommandConfig.key, + updatedAt: chatCommandConfig.updatedAt, +} as const satisfies Record; + export class ChatCommandsService { constructor( private readonly drizzle: DrizzleService, - private readonly systemWriter: ChatSystemWriter, - private readonly wallet: WalletCommands, private readonly directory: AdminUserDirectory, - private readonly audit: AuditWritePort, - private readonly transport: RealtimeTransport, - private readonly events: EventBus, private readonly blockWriter: ChatBlockWriter, - private readonly gameReporting: AdminGameReporting, - private readonly roomAccess: ChatRoomAccess, - private readonly cache: CacheAdapter, - private readonly idempotencyTtlMs = COMMAND_IDEMPOTENCY_TTL_MS, + private readonly giftCommands: GiftCommands, + private readonly rainCommands: RainCommands, + private readonly transport: RealtimeTransport, + private readonly audit: AuditWritePort, ) {} async listCommands(includeDisabled = false): Promise { @@ -191,669 +119,29 @@ export class ChatCommandsService { return rows.map(toDescriptor); } - async searchMentions(q: string, limit: number, viewerId: Uuid) { - const ids = await this.directory.findPlayerIds(q, limit); - if (ids.length === 0) { - return []; - } - const excluded = new Set(await this.blockWriter.getExcludedUserIds(viewerId)); - const filteredIds = ids.filter((id) => !excluded.has(id)); - if (filteredIds.length === 0) { - return []; - } - const summaries = await this.directory.lookupPlayers(filteredIds); - return summaries.map((s) => ({ userId: s.userId, username: s.username })); - } - - async searchPlayers(q: string, limit: number, viewerId: Uuid): Promise { - const ids = await this.directory.findPlayerIds(q, limit); - if (ids.length === 0) { - return []; - } - const excluded = new Set(await this.blockWriter.getExcludedUserIds(viewerId)); - const filteredIds = ids.filter((id) => !excluded.has(id)); - if (filteredIds.length === 0) { - return []; - } - const summaries = await this.directory.lookupPlayers(filteredIds); - return summaries.map((s) => ({ - userId: s.userId, - username: s.username, - avatarUrl: s.avatarUrl, - level: s.level, - })); - } - - async getPlayerProfile(userId: Uuid, viewerId: Uuid): Promise { - const summaries = await this.directory.lookupPlayers([userId]); - const summary = summaries.find((s) => s.userId === userId); - if (!summary) { - throw new ChatPlayerNotFoundError(userId); - } - const isSelf = userId === viewerId; - const stats = isSelf ? await this.gameReporting.getPlayerStats(userId) : null; - return { - userId: summary.userId, - username: summary.username, - avatarUrl: summary.avatarUrl, - level: summary.level, - joinedAt: isSelf ? summary.createdAt.toISOString() : null, - totalWagered: stats?.totalWagered ?? null, - totalBets: stats?.totalBets ?? null, - currency: isSelf ? summary.currency : null, - }; - } - - async executeCommand(input: ExecuteInput, actorId: Uuid): Promise { - if (input.roomId) { - try { - await this.roomAccess.verifyRoomAccess(input.roomId, actorId); - } catch (error) { - if (error instanceof Error && error.name === 'ChatRoomNotMemberError') { - throw new ChatRoomNotMemberError(input.roomId); - } - throw error; - } - } - const [row] = await this.drizzle.db - .select() - .from(chatCommandConfig) - .where(eq(chatCommandConfig.key, input.type)) - .limit(1); - - if (!row || !row.enabled) { - throw new CommandDisabledError(input.type); - } - - if (input.type === 'gift') { - return this.handleGift(input, actorId, row.config ?? null); - } - if (input.type === 'donate') { - return this.handleDonate(input, actorId, row.config ?? null); - } - if (input.type === 'block' || input.type === 'ignore') { - return this.handleBlockAction(input, actorId); - } - if (input.type === 'rain') { - return this.handleRain(input, actorId, row.config ?? null); - } - // exhaustive — TypeScript cannot narrow a union-literal discriminant ('block'|'ignore') away - throw new CommandDisabledError(input.type); - } - - private async findCommandReplay( - commandType: 'gift' | 'rain' | 'donate', - actorId: Uuid, - idempotencyKey: Uuid, - fingerprint: string, - ): Promise { - const record = await this.cache.get( - this.idempotencyCacheKey(commandType, actorId, idempotencyKey), - ); - if (!record) { - return null; - } - if (record.fingerprint !== fingerprint) { - throw new ChatCommandIdempotencyKeyReuseError(); - } - if (!record.result) { - throw new ConcurrentCommandReplayError(); - } - return record.result; - } - - private idempotencyCacheKey( - commandType: 'gift' | 'rain' | 'donate', - actorId: Uuid, - idempotencyKey: Uuid, - ): string { - return `chat-command:idempotency:${actorId}:${commandType}:${idempotencyKey}`; - } - - private async reserveCommandIdempotency( - commandType: 'gift' | 'rain' | 'donate', - actorId: Uuid, - idempotencyKey: Uuid, - fingerprint: string, - ): Promise { - const reserved = await this.cache.setIfAbsent( - this.idempotencyCacheKey(commandType, actorId, idempotencyKey), - { fingerprint, result: null } satisfies CommandIdempotencyRecord, - { ttlMs: this.idempotencyTtlMs }, - ); - if (!reserved) { - throw new ConcurrentCommandReplayError(); - } - } - - private async completeCommandIdempotency( - commandType: 'gift' | 'rain' | 'donate', - actorId: Uuid, - idempotencyKey: Uuid, - fingerprint: string, - result: ChatSystemMessage, - ): Promise { - await this.cache.set( - this.idempotencyCacheKey(commandType, actorId, idempotencyKey), - { fingerprint, result } satisfies CommandIdempotencyRecord, - { ttlMs: this.idempotencyTtlMs }, - ); - } - - private async runWithCommandReservation( - commandType: 'gift' | 'rain' | 'donate', - actorId: Uuid, - idempotencyKey: Uuid, - work: () => Promise, - ): Promise { - try { - return await work(); - } catch (error) { - await this.cache.delete(this.idempotencyCacheKey(commandType, actorId, idempotencyKey)); - throw error; - } - } - - // Exact, case-insensitive username resolution for callers holding a complete, already-known - // username (never a partial search term) - `/donate`, `/block`, `/ignore`. Distinct from - // findPlayerIds' capped fuzzy substring search, which can silently drop the real match once - // more than 20 unrelated accounts substring-collide with a short/common username. - private async resolveExactPlayer(username: string) { - const summary = await this.directory.getPlayerByUsername(username); - if (!summary) { - throw new ChatPlayerNotFoundError(username); - } - return summary; - } - - private async handleGift( - input: GiftInput, - actorId: Uuid, - config: CommandConfig | null, - ): Promise { - if ( - config?.maxAmount !== undefined && - moneyToNumber(input.amount) > moneyToNumber(config.maxAmount) - ) { - throw new ExceedsLimitError(); - } - if ( - config?.minAmount !== undefined && - moneyToNumber(input.amount) < moneyToNumber(config.minAmount) - ) { - throw new BelowMinimumError(); - } - - const fingerprint = fingerprintCommand(input); - const replay = await this.findCommandReplay('gift', actorId, input.idempotencyKey, fingerprint); - if (replay) { - return replay; - } - await this.reserveCommandIdempotency('gift', actorId, input.idempotencyKey, fingerprint); - - // Resolve sender's username for the gift card metadata. - const senderSummaries = await this.directory.lookupPlayers([actorId]); - const senderSummary = senderSummaries.find((s) => s.userId === actorId); - if (!senderSummary) { - throw new ChatPlayerNotFoundError(actorId); - } - const senderUsername = senderSummary.username; - - const { msg, giftId, currency } = await this.runWithCommandReservation( - 'gift', - actorId, - input.idempotencyKey, - () => - this.drizzle.db.transaction(async (tx) => { - const debit = await this.wallet.debit(tx, { - userId: actorId, - amount: input.amount, - type: 'gift', - }); - if (!debit.ok) { - throw new InsufficientBalanceError(); - } - - const [giftRow] = await tx - .insert(chatGift) - .values({ - senderId: actorId, - senderUsername, - amount: input.amount, - currency: debit.currency, - roomId: input.roomId, - // messageId will be updated after postSystemMessage; we use a placeholder UUID - // that is immediately overwritten by the UPDATE below in the same transaction. - // Pattern: insert with a temporary value, then set the real foreign reference. - messageId: '00000000-0000-0000-0000-000000000000', - }) - .returning(); - - if (!giftRow) { - throw new InsufficientBalanceError(); - } - - const systemMsg = await this.systemWriter.postSystemMessage({ - roomId: input.roomId, - actorId, - tx, - metadata: { - command: 'gift', - giftId: giftRow.id, - senderId: actorId, - senderUsername, - amount: input.amount, - currency: debit.currency, - }, - }); - - // Back-fill the real message id now that we have it. - await tx - .update(chatGift) - .set({ messageId: systemMsg.id }) - .where(eq(chatGift.id, giftRow.id)); - - await this.audit.recordInTransaction(tx, { - actorId, - actorType: 'player', - action: 'chat.gift', - resourceType: 'chat_gift', - resourceId: giftRow.id, - before: null, - after: { amount: input.amount, roomId: input.roomId }, - }); - - return { msg: systemMsg, 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 - // it did not auto-publish - publish only now that this transaction has committed. - void this.transport.publish(chatChannel(input.roomId), msg); - - void this.events.emit('chat.gift.sent', { - giftId, - senderId: actorId, - senderUsername, - amount: input.amount, - currency, - roomId: input.roomId, - messageId: msg.id, - }); - - return msg; - } - - async getGift(giftId: Uuid, viewerId: Uuid): Promise { - const rows = await this.drizzle.db - .select() - .from(chatGift) - .where(eq(chatGift.id, giftId)) - .limit(1); - const row = findOneOrThrow(rows, new GiftNotFoundError(giftId)); - await this.roomAccess.verifyRoomAccess(row.roomId, viewerId); - const serialized = serializeRow(row, { - dateFields: ['claimedAt', 'createdAt'], - decimalFields: ['amount'], - }); - return { - id: serialized.id, - senderId: serialized.senderId, - senderUsername: serialized.senderUsername, - amount: serialized.amount, - currency: serialized.currency, - claimedBy: serialized.claimedBy ?? null, - claimedByUsername: serialized.claimedByUsername ?? null, - claimedAt: serialized.claimedAt ?? null, - createdAt: serialized.createdAt, - }; - } - - async claimGift(giftId: Uuid, claimerId: Uuid): Promise { - // Resolve claimer's username upfront — if the claimer is somehow not in the - // directory that is a hard server error (defensive guard). - const claimerSummaries = await this.directory.lookupPlayers([claimerId]); - const claimerSummary = claimerSummaries.find((s) => s.userId === claimerId); - if (!claimerSummary) { - throw new GiftNotFoundError(claimerId); - } - const claimerUsername = claimerSummary.username; - - // Fetch gift to check self-claim BEFORE attempting the atomic update. - const existing = await this.drizzle.db - .select() - .from(chatGift) - .where(eq(chatGift.id, giftId)) - .limit(1); - const giftRow = findOneOrThrow(existing, new GiftNotFoundError(giftId)); - await this.roomAccess.verifyRoomAccess(giftRow.roomId, claimerId); - - if (giftRow.senderId === claimerId) { - throw new GiftSelfClaimError(); - } - - const { claimed, currency, roomId } = await this.drizzle.db.transaction(async (tx) => { - const claimedAt = new Date(); - const results = await tx - .update(chatGift) - .set({ claimedBy: claimerId, claimedByUsername: claimerUsername, claimedAt }) - .where(and(eq(chatGift.id, giftId), isNull(chatGift.claimedBy))) - .returning(); - - if (results.length === 0) { - // Another claimer won the race. - throw new GiftAlreadyClaimedError(); - } - - const updated = findOneOrThrow(results, new GiftAlreadyClaimedError()); - - const credit = await this.wallet.credit(tx, { - userId: claimerId, - amount: updated.amount, - currency: updated.currency, - type: 'gift', - }); - if (!credit.ok) { - throw new GiftNotFoundError(claimerId); - } - - await this.audit.recordInTransaction(tx, { - actorId: claimerId, - actorType: 'player', - action: 'chat.gift.claimed', - resourceType: 'chat_gift', - resourceId: giftId, - before: null, - after: { claimedBy: claimerId, amount: updated.amount }, - }); - - return { claimed: updated, currency: updated.currency, roomId: updated.roomId }; - }); - - void this.transport.publish(chatChannel(roomId), { - event: 'gift.claimed', - giftId, - claimedBy: claimerId, - claimedByUsername: claimerUsername, - claimedAt: claimed.claimedAt?.toISOString() ?? new Date().toISOString(), - }); - - void this.events.emit('chat.gift.claimed', { - giftId, - claimerId, - claimerUsername, - senderId: giftRow.senderId, - amount: claimed.amount, - currency, - roomId, - }); - - return { - claimedBy: claimerId, - claimedByUsername: claimerUsername, - claimedAt: claimed.claimedAt?.toISOString() ?? new Date().toISOString(), - }; - } - - private async handleRain( - input: RainInput, - actorId: Uuid, - config: CommandConfig | null, - ): Promise { - if ( - config?.maxAmount !== undefined && - moneyToNumber(input.amount) > moneyToNumber(config.maxAmount) - ) { - throw new ExceedsLimitError(); - } - if ( - config?.minAmount !== undefined && - moneyToNumber(input.amount) < moneyToNumber(config.minAmount) - ) { - throw new BelowMinimumError(); - } - - const configMax = config?.maxRecipients ?? 50; - if (input.recipientCount > configMax) { - throw new ExceedsLimitError(); - } - const amountUnits = Math.floor(moneyToNumber(input.amount)); - if (input.recipientCount > amountUnits) { - throw new TooManyRecipientsError(); - } - const fingerprint = fingerprintCommand(input); - const replay = await this.findCommandReplay('rain', actorId, input.idempotencyKey, fingerprint); - if (replay) { - return replay; - } - await this.reserveCommandIdempotency('rain', actorId, input.idempotencyKey, fingerprint); - - const allOnline = await this.transport.getOnlineUserIds(chatChannel(input.roomId)); - const recipients = shuffleArray(allOnline.filter((id) => id !== actorId)).slice( - 0, - input.recipientCount, - ); - if (recipients.length === 0) { - throw new NoOnlineUsersError(); - } - - const { msg, currency, totalDistributed } = await this.runWithCommandReservation( - 'rain', - actorId, - input.idempotencyKey, - () => - this.drizzle.db.transaction(async (tx) => { - const splitResult = await tx.execute( - sql`SELECT - (floor(floor(${input.amount}::numeric) / ${recipients.length}))::text AS per_recipient, - (floor(floor(${input.amount}::numeric) / ${recipients.length}) * ${recipients.length})::text AS total_distributed`, - ); - const { per_recipient: perRecipient, total_distributed: totalDistributed } = splitResult - .rows[0] as { - per_recipient: string; - total_distributed: string; - }; - const debit = await this.wallet.debit(tx, { - userId: actorId, - amount: totalDistributed, - type: 'rain', - }); - if (!debit.ok) { - throw new InsufficientBalanceError(); - } - const credits = await mapConcurrent(recipients, 10, (userId) => - this.wallet.credit(tx, { - userId, - amount: perRecipient, - currency: debit.currency, - type: 'rain', - }), - ); - if (credits.some((c) => !c.ok)) { - throw new RainCreditError(); - } - const systemMsg = await this.systemWriter.postSystemMessage({ - roomId: input.roomId, - actorId, - tx, - metadata: { - command: 'rain', - fromUserId: actorId, - amount: totalDistributed, - currency: debit.currency, - recipientCount: recipients.length, - perRecipient, - }, - }); - - await this.audit.recordInTransaction(tx, { - actorId, - actorType: 'player', - action: 'chat.rain', - resourceType: 'chat_room', - resourceId: input.roomId, - before: null, - after: { amount: totalDistributed, recipientCount: recipients.length }, - }); - - return { msg: systemMsg, currency: debit.currency, totalDistributed }; - }), - ); - await this.completeCommandIdempotency('rain', actorId, input.idempotencyKey, fingerprint, msg); - - void this.transport.publish(chatChannel(input.roomId), msg); - - void this.events.emit('chat.rain.distributed', { - fromUserId: actorId, - recipients, - recipientCount: recipients.length, - totalAmount: totalDistributed, - currency, - roomId: input.roomId, - }); - return msg; - } - - private async handleDonate( - input: DonateInput, - actorId: Uuid, - config: CommandConfig | null, - ): Promise { - if ( - config?.maxAmount !== undefined && - moneyToNumber(input.amount) > moneyToNumber(config.maxAmount) - ) { - throw new ExceedsLimitError(); - } - if ( - config?.minAmount !== undefined && - moneyToNumber(input.amount) < moneyToNumber(config.minAmount) - ) { - throw new BelowMinimumError(); - } - - const target = await this.resolveExactPlayer(input.targetUsername); - - if (target.userId === actorId) { - throw new DonateSelfError(); - } - - const senderSummaries = await this.directory.lookupPlayers([actorId]); - const sender = senderSummaries.find((s) => s.userId === actorId); - if (!sender) { - throw new ChatPlayerNotFoundError(actorId); - } - - const fingerprint = fingerprintCommand(input); - const replay = await this.findCommandReplay( - 'donate', - actorId, - input.idempotencyKey, - fingerprint, - ); - if (replay) { - return replay; - } - await this.reserveCommandIdempotency('donate', actorId, input.idempotencyKey, fingerprint); - - const { msg, currency } = await this.runWithCommandReservation( - 'donate', - actorId, - input.idempotencyKey, - () => - this.drizzle.db.transaction(async (tx) => { - const debit = await this.wallet.debit(tx, { - userId: actorId, - amount: input.amount, - type: 'tip', - }); - if (!debit.ok) { - throw new InsufficientBalanceError(); - } - - const credit = await this.wallet.credit(tx, { - userId: target.userId, - amount: input.amount, - currency: debit.currency, - type: 'tip', - }); - if (!credit.ok) { - throw new ChatPlayerNotFoundError(target.userId); - } - - const systemMsg = await this.systemWriter.postSystemMessage({ - roomId: input.roomId, - actorId, - tx, - metadata: { - command: 'donate', - recipientId: target.userId, - recipientUsername: target.username, - amount: input.amount, - currency: debit.currency, - }, - }); - - await this.audit.recordInTransaction(tx, { - actorId, - actorType: 'player', - action: 'chat.donate', - resourceType: 'chat_donation', - resourceId: systemMsg.id, - before: null, - after: { recipientId: target.userId, amount: input.amount, currency: debit.currency }, - }); - - return { msg: systemMsg, currency: debit.currency }; - }), - ); - await this.completeCommandIdempotency( - 'donate', - actorId, - input.idempotencyKey, - fingerprint, - msg, - ); - - void this.transport.publish(chatChannel(input.roomId), msg); - - void this.events.emit('chat.donate.sent', { - senderId: actorId, - senderUsername: sender.username, - recipientId: target.userId, - recipientUsername: target.username, - amount: input.amount, - currency, - roomId: input.roomId, - }); - - return msg; - } - - /** Command must be used to update users that chat commands setup has been invalidated and needs to be refetched */ - private async handleBlockAction( - input: BlockActionInput, - actorId: Uuid, - ): Promise { - const summary = await this.resolveExactPlayer(input.targetUsername); - if (summary.userId === actorId) { - throw new SelfModerationActionError(); - } - if (input.type === 'block') { - await this.blockWriter.blockUser(actorId, summary.userId); - } else { - await this.blockWriter.ignoreUser(actorId, summary.userId); - } - return this.systemWriter.postSystemMessage({ - roomId: input.roomId, - actorId, - metadata: { - command: input.type, - targetUserId: summary.userId, - displayName: summary.username, - }, - }); + async adminListCommands({ + page, + limit, + sortBy, + sortOrder, + }: { + page: number; + limit: number; + sortBy: AdminCommandSortBy; + sortOrder: SortOrder; + }) { + const dir = sortOrder === 'asc' ? asc : desc; + const col = ADMIN_COMMAND_SORT_COLUMNS[sortBy]; + const [rows, [{ n }]] = await Promise.all([ + this.drizzle.db + .select() + .from(chatCommandConfig) + .orderBy(dir(col)) + .limit(limit) + .offset(pageToOffset(page, limit)), + this.drizzle.db.select({ n: count() }).from(chatCommandConfig), + ]); + return { items: rows.map(toDescriptor), total: Number(n), page, limit }; } async adminUpdateCommand( @@ -890,4 +178,113 @@ export class ChatCommandsService { }); return toDescriptor(row); } + + async searchMentions(q: string, limit: number, viewerId: Uuid) { + const ids = await this.directory.findPlayerIds(q, limit); + if (ids.length === 0) { + return []; + } + const excluded = new Set(await this.blockWriter.getExcludedUserIds(viewerId)); + const filteredIds = ids.filter((id) => !excluded.has(id)); + if (filteredIds.length === 0) { + return []; + } + const summaries = await this.directory.lookupPlayers(filteredIds); + return summaries.map((s) => ({ userId: s.userId, username: s.username })); + } + + async postGift(input: PostGiftInput, actorId: Uuid): Promise { + const result = await this.giftCommands.sendGift(input, actorId); + if (result.ok) { + return result.message; + } + switch (result.reason) { + case 'disabled': + throw new CommandDisabledError('gift'); + case 'insufficient_balance': + throw new InsufficientBalanceError(); + case 'exceeds_limit': + throw new ExceedsLimitError(); + case 'below_minimum': + throw new BelowMinimumError(); + case 'idempotency_key_reuse': + throw new ChatCommandIdempotencyKeyReuseError(); + case 'concurrent_replay': + throw new ConcurrentCommandReplayError(); + case 'room_not_member': + throw new ChatRoomNotMemberError(input.roomId); + } + } + + async claimGift(giftId: Uuid, claimerId: Uuid): Promise { + const result = await this.giftCommands.claimGift(giftId, claimerId); + if (result.ok) { + return { + claimedBy: result.claimedBy, + claimedByUsername: result.claimedByUsername, + claimedAt: result.claimedAt, + }; + } + switch (result.reason) { + case 'gift_not_found': + throw new GiftNotFoundError(giftId); + case 'already_claimed': + throw new GiftAlreadyClaimedError(); + case 'self_claim': + throw new GiftSelfClaimError(); + case 'room_not_member': + throw new ChatRoomNotMemberError(result.roomId ?? null); + case 'gift_credit_failed': + throw new GiftCreditError(); + } + } + + async getGift(giftId: Uuid, viewerId: Uuid): Promise { + const result = await this.giftCommands.getGift(giftId, viewerId); + if (result.ok) { + return result.gift; + } + switch (result.reason) { + case 'gift_not_found': + throw new GiftNotFoundError(giftId); + case 'room_not_member': + throw new ChatRoomNotMemberError(result.roomId ?? null); + } + } + + // Zero rain business logic here - the RAIN_COMMANDS port (bound by + // social-transfers) owns money movement, limit checks, idempotency, and + // posting/publishing the resulting chat message. This module only resolves + // 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 { + const onlineUserIds = await this.transport.getOnlineUserIds(chatChannel(input.roomId)); + const result = await this.rainCommands.sendRain({ ...input, onlineUserIds }, actorId); + if (result.ok) { + return result.message; + } + switch (result.reason) { + case 'disabled': + throw new CommandDisabledError('rain'); + case 'insufficient_balance': + throw new InsufficientBalanceError(); + case 'exceeds_limit': + throw new ExceedsLimitError(); + case 'below_minimum': + throw new BelowMinimumError(); + case 'no_online_users': + throw new NoOnlineUsersError(); + case 'too_many_recipients': + throw new TooManyRecipientsError(); + case 'rain_credit_failed': + throw new RainCreditError(); + case 'idempotency_key_reuse': + throw new ChatCommandIdempotencyKeyReuseError(); + case 'concurrent_replay': + throw new ConcurrentCommandReplayError(); + case 'room_not_member': + throw new ChatRoomNotMemberError(input.roomId); + } + } } diff --git a/packages/core/src/engagement/chat/__tests__/chat.service.int.test.ts b/packages/core/src/engagement/chat/__tests__/chat.service.int.test.ts index f4882fde..173b897a 100644 --- a/packages/core/src/engagement/chat/__tests__/chat.service.int.test.ts +++ b/packages/core/src/engagement/chat/__tests__/chat.service.int.test.ts @@ -471,12 +471,33 @@ describe('ChatService block list (real PG)', () => { await svc.unblockUser(blockerId, blockedId, NO_CLIENT_META); await svc.unblockUser(blockerId, blockedId, NO_CLIENT_META); - expect(await db.drizzle.db.select().from(chatUserBlock)).toHaveLength(0); + // Soft-delete: the row survives with removedAt set, not physically removed. + const rows = await db.drizzle.db.select().from(chatUserBlock); + expect(rows).toHaveLength(1); + expect(rows[0]?.removedAt).not.toBeNull(); expect( events.emit.mock.calls.filter(([topic]) => topic === 'chat.user.unblocked'), ).toHaveLength(1); }); + it('re-blocking after an unblock inserts a fresh active row and re-excludes the sender', async () => { + const { svc, events } = makeService(); + const blockerId = randomUUID(); + const blockedId = randomUUID(); + await svc.blockUser(blockerId, blockedId); + await svc.unblockUser(blockerId, blockedId, NO_CLIENT_META); + + await svc.blockUser(blockerId, blockedId); + + const rows = await db.drizzle.db.select().from(chatUserBlock); + expect(rows).toHaveLength(2); + expect(rows.filter((r) => r.removedAt === null)).toHaveLength(1); + expect(events.emit.mock.calls.filter(([topic]) => topic === 'chat.user.blocked')).toHaveLength( + 2, + ); + expect(await svc.getExcludedUserIds(blockerId)).toContain(blockedId); + }); + it('lists the blocked ids newest-first', async () => { const { svc } = makeService(); const blockerId = randomUUID(); @@ -487,9 +508,112 @@ describe('ChatService block list (real PG)', () => { { blockerId, blockedId: second, createdAt: new Date('2026-02-01T00:00:00.000Z') }, ]); - const rows = await svc.listBlockedUsers(blockerId); + const { items } = await svc.listBlockedUsers({ + blockerId, + page: 1, + limit: 100, + sortBy: 'createdAt', + sortOrder: 'desc', + }); - expect(rows.map((r) => r.blockedId)).toEqual([second, first]); + expect(items.map((r) => r.blockedId)).toEqual([second, first]); + }); + + it('paginates and sorts oldest-first when asked, reporting the total across all pages', async () => { + const { svc } = makeService(); + const blockerId = randomUUID(); + const first = randomUUID(); + const second = randomUUID(); + const third = randomUUID(); + await db.drizzle.db.insert(chatUserBlock).values([ + { blockerId, blockedId: first, createdAt: new Date('2026-01-01T00:00:00.000Z') }, + { blockerId, blockedId: second, createdAt: new Date('2026-02-01T00:00:00.000Z') }, + { blockerId, blockedId: third, createdAt: new Date('2026-03-01T00:00:00.000Z') }, + ]); + + const page1 = await svc.listBlockedUsers({ + blockerId, + page: 1, + limit: 2, + sortBy: 'createdAt', + sortOrder: 'asc', + }); + const page2 = await svc.listBlockedUsers({ + blockerId, + page: 2, + limit: 2, + sortBy: 'createdAt', + sortOrder: 'asc', + }); + + expect(page1.items.map((r) => r.blockedId)).toEqual([first, second]); + expect(page1.total).toBe(3); + expect(page2.items.map((r) => r.blockedId)).toEqual([third]); + expect(page2.total).toBe(3); + }); + + it('excludes an unblocked user from the list', async () => { + const { svc } = makeService(); + const blockerId = randomUUID(); + const blockedId = randomUUID(); + await svc.blockUser(blockerId, blockedId); + await svc.unblockUser(blockerId, blockedId); + + const { items, total } = await svc.listBlockedUsers({ + blockerId, + page: 1, + limit: 100, + sortBy: 'createdAt', + sortOrder: 'desc', + }); + + expect(items).toHaveLength(0); + expect(total).toBe(0); + }); +}); + +describe('ChatService.adminListBlockedUsers (real PG, site-wide)', () => { + it('lists every users blocks, not just one callers, including who blocked whom', async () => { + const { svc } = makeService(); + const blockerA = randomUUID(); + const blockerB = randomUUID(); + const blockedA = randomUUID(); + const blockedB = randomUUID(); + await svc.blockUser(blockerA, blockedA); + await svc.blockUser(blockerB, blockedB); + + const { items, total } = await svc.adminListBlockedUsers({ + page: 1, + limit: 100, + sortBy: 'createdAt', + sortOrder: 'desc', + }); + + expect(total).toBe(2); + expect(items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ blockerId: blockerA, blockedId: blockedA }), + expect.objectContaining({ blockerId: blockerB, blockedId: blockedB }), + ]), + ); + }); + + it('excludes an unblocked (soft-removed) relationship', async () => { + const { svc } = makeService(); + const blockerId = randomUUID(); + const blockedId = randomUUID(); + await svc.blockUser(blockerId, blockedId); + await svc.unblockUser(blockerId, blockedId); + + const { items, total } = await svc.adminListBlockedUsers({ + page: 1, + limit: 100, + sortBy: 'createdAt', + sortOrder: 'desc', + }); + + expect(items).toHaveLength(0); + expect(total).toBe(0); }); }); @@ -524,7 +648,10 @@ describe('ChatService ignore list (real PG)', () => { await svc.unignoreUser(ignorerId, ignoredId, NO_CLIENT_META); await svc.unignoreUser(ignorerId, ignoredId, NO_CLIENT_META); - expect(await db.drizzle.db.select().from(chatUserIgnore)).toHaveLength(0); + // Soft-delete: the row survives with removedAt set, not physically removed. + const rows = await db.drizzle.db.select().from(chatUserIgnore); + expect(rows).toHaveLength(1); + expect(rows[0]?.removedAt).not.toBeNull(); expect( events.emit.mock.calls.filter(([topic]) => topic === 'chat.user.unignored'), ).toHaveLength(1); @@ -540,9 +667,67 @@ describe('ChatService ignore list (real PG)', () => { { ignorerId, ignoredId: second, createdAt: new Date('2026-02-01T00:00:00.000Z') }, ]); - const rows = await svc.listIgnoredUsers(ignorerId); + const { items } = await svc.listIgnoredUsers({ + ignorerId, + page: 1, + limit: 100, + sortBy: 'createdAt', + sortOrder: 'desc', + }); + + expect(items.map((r) => r.ignoredId)).toEqual([second, first]); + }); + + it('paginates and sorts oldest-first when asked, reporting the total across all pages', async () => { + const { svc } = makeService(); + const ignorerId = randomUUID(); + const first = randomUUID(); + const second = randomUUID(); + const third = randomUUID(); + await db.drizzle.db.insert(chatUserIgnore).values([ + { ignorerId, ignoredId: first, createdAt: new Date('2026-01-01T00:00:00.000Z') }, + { ignorerId, ignoredId: second, createdAt: new Date('2026-02-01T00:00:00.000Z') }, + { ignorerId, ignoredId: third, createdAt: new Date('2026-03-01T00:00:00.000Z') }, + ]); + + const page1 = await svc.listIgnoredUsers({ + ignorerId, + page: 1, + limit: 2, + sortBy: 'createdAt', + sortOrder: 'asc', + }); + const page2 = await svc.listIgnoredUsers({ + ignorerId, + page: 2, + limit: 2, + sortBy: 'createdAt', + sortOrder: 'asc', + }); + + expect(page1.items.map((r) => r.ignoredId)).toEqual([first, second]); + expect(page1.total).toBe(3); + expect(page2.items.map((r) => r.ignoredId)).toEqual([third]); + expect(page2.total).toBe(3); + }); + + it('excludes an unignored user from the list', async () => { + const { svc } = makeService(); + const ignorerId = randomUUID(); + const ignoredId = randomUUID(); + await svc.ignoreUser(ignorerId, ignoredId); + await svc.unignoreUser(ignorerId, ignoredId); + + const { items, total } = await svc.listIgnoredUsers({ + ignorerId, + page: 1, + limit: 100, + sortBy: 'createdAt', + sortOrder: 'desc', + }); - expect(rows.map((r) => r.ignoredId)).toEqual([second, first]); + expect(items).toHaveLength(0); + expect(total).toBe(0); }); it('a block and an ignore are independent relationships (blocking does not ignore, and vice versa)', async () => { @@ -553,8 +738,67 @@ describe('ChatService ignore list (real PG)', () => { await svc.blockUser(viewerId, blockedId); await svc.ignoreUser(viewerId, ignoredId); - expect((await svc.listBlockedUsers(viewerId)).map((r) => r.blockedId)).toEqual([blockedId]); - expect((await svc.listIgnoredUsers(viewerId)).map((r) => r.ignoredId)).toEqual([ignoredId]); + const defaultPage = { + page: 1, + limit: 100, + sortBy: 'createdAt' as const, + sortOrder: 'desc' as const, + }; + expect( + (await svc.listBlockedUsers({ blockerId: viewerId, ...defaultPage })).items.map( + (r) => r.blockedId, + ), + ).toEqual([blockedId]); + expect( + (await svc.listIgnoredUsers({ ignorerId: viewerId, ...defaultPage })).items.map( + (r) => r.ignoredId, + ), + ).toEqual([ignoredId]); + }); +}); + +describe('ChatService.adminListIgnoredUsers (real PG, site-wide)', () => { + it('lists every users ignores, not just one callers, including who ignored whom', async () => { + const { svc } = makeService(); + const ignorerA = randomUUID(); + const ignorerB = randomUUID(); + const ignoredA = randomUUID(); + const ignoredB = randomUUID(); + await svc.ignoreUser(ignorerA, ignoredA); + await svc.ignoreUser(ignorerB, ignoredB); + + const { items, total } = await svc.adminListIgnoredUsers({ + page: 1, + limit: 100, + sortBy: 'createdAt', + sortOrder: 'desc', + }); + + expect(total).toBe(2); + expect(items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ ignorerId: ignorerA, ignoredId: ignoredA }), + expect.objectContaining({ ignorerId: ignorerB, ignoredId: ignoredB }), + ]), + ); + }); + + it('excludes an unignored (soft-removed) relationship', async () => { + const { svc } = makeService(); + const ignorerId = randomUUID(); + const ignoredId = randomUUID(); + await svc.ignoreUser(ignorerId, ignoredId); + await svc.unignoreUser(ignorerId, ignoredId); + + const { items, total } = await svc.adminListIgnoredUsers({ + page: 1, + limit: 100, + sortBy: 'createdAt', + sortOrder: 'desc', + }); + + expect(items).toHaveLength(0); + expect(total).toBe(0); }); }); diff --git a/packages/core/src/engagement/chat/contract/index.ts b/packages/core/src/engagement/chat/contract/index.ts index ff0587ab..448b4a5d 100644 --- a/packages/core/src/engagement/chat/contract/index.ts +++ b/packages/core/src/engagement/chat/contract/index.ts @@ -87,11 +87,33 @@ export const BlockedUserSchema = z.object({ createdAt: TimestampSchema, }); +export const BLOCKED_USER_SORT_BY_VALUES = ['createdAt'] as const; +export const BlockedUserSortBySchema = z.enum(BLOCKED_USER_SORT_BY_VALUES).default('createdAt'); +export type BlockedUserSortBy = z.infer; + export const IgnoredUserSchema = z.object({ ignoredId: UuidSchema, createdAt: TimestampSchema, }); +export const IGNORED_USER_SORT_BY_VALUES = ['createdAt'] as const; +export const IgnoredUserSortBySchema = z.enum(IGNORED_USER_SORT_BY_VALUES).default('createdAt'); +export type IgnoredUserSortBy = z.infer; + +// Backoffice-only, site-wide views (distinct from the per-caller BlockedUserSchema/ +// IgnoredUserSchema above): who blocked/ignored whom, across every user. +export const AdminBlockedUserSchema = z.object({ + blockerId: UuidSchema, + blockedId: UuidSchema, + createdAt: TimestampSchema, +}); + +export const AdminIgnoredUserSchema = z.object({ + ignorerId: UuidSchema, + ignoredId: UuidSchema, + createdAt: TimestampSchema, +}); + export const ChatOnlineCountSchema = z.object({ count: z.number().int().min(0) }); // `.loose()` keeps this an open union so a managed-vendor overlay (eg Ably) can return extra fields without a contract change. @@ -157,7 +179,14 @@ export const chatContract = { listBlockedUsers: oc .route({ method: 'GET', path: '/chat/blocks' }) - .output(z.array(BlockedUserSchema)), + .input( + z.object({ + ...PageQuerySchema.shape, + sortBy: BlockedUserSortBySchema, + sortOrder: SortOrderSchema.default('desc'), + }), + ) + .output(paginated(BlockedUserSchema)), blockUser: oc .route({ method: 'POST', path: '/chat/blocks' }) @@ -171,7 +200,14 @@ export const chatContract = { listIgnoredUsers: oc .route({ method: 'GET', path: '/chat/ignores' }) - .output(z.array(IgnoredUserSchema)), + .input( + z.object({ + ...PageQuerySchema.shape, + sortBy: IgnoredUserSortBySchema, + sortOrder: SortOrderSchema.default('desc'), + }), + ) + .output(paginated(IgnoredUserSchema)), ignoreUser: oc .route({ method: 'POST', path: '/chat/ignores' }) @@ -269,4 +305,26 @@ export const chatContract = { .route({ method: 'DELETE', path: '/backoffice/chat/rooms/{id}' }) .input(IdInputSchema) .output(z.object({ success: z.literal(true) })), + + adminListBlockedUsers: oc + .route({ method: 'GET', path: '/backoffice/chat/blocks' }) + .input( + z.object({ + ...PageQuerySchema.shape, + sortBy: BlockedUserSortBySchema, + sortOrder: SortOrderSchema.default('desc'), + }), + ) + .output(paginated(AdminBlockedUserSchema)), + + adminListIgnoredUsers: oc + .route({ method: 'GET', path: '/backoffice/chat/ignores' }) + .input( + z.object({ + ...PageQuerySchema.shape, + sortBy: IgnoredUserSortBySchema, + sortOrder: SortOrderSchema.default('desc'), + }), + ) + .output(paginated(AdminIgnoredUserSchema)), }; diff --git a/packages/core/src/engagement/chat/drizzle/migrations/0004_panoramic_robin_chapel.sql b/packages/core/src/engagement/chat/drizzle/migrations/0004_panoramic_robin_chapel.sql new file mode 100644 index 00000000..20472617 --- /dev/null +++ b/packages/core/src/engagement/chat/drizzle/migrations/0004_panoramic_robin_chapel.sql @@ -0,0 +1,6 @@ +DROP INDEX "chat_user_block_pair_key";--> statement-breakpoint +DROP INDEX "chat_user_ignore_pair_key";--> statement-breakpoint +ALTER TABLE "chat_user_block" ADD COLUMN "removed_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "chat_user_ignore" ADD COLUMN "removed_at" timestamp with time zone;--> statement-breakpoint +CREATE UNIQUE INDEX "chat_user_block_pair_key" ON "chat_user_block" USING btree ("blocker_id","blocked_id") WHERE "chat_user_block"."removed_at" IS NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "chat_user_ignore_pair_key" ON "chat_user_ignore" USING btree ("ignorer_id","ignored_id") WHERE "chat_user_ignore"."removed_at" IS NULL; \ No newline at end of file diff --git a/packages/core/src/engagement/chat/drizzle/migrations/meta/0004_snapshot.json b/packages/core/src/engagement/chat/drizzle/migrations/meta/0004_snapshot.json new file mode 100644 index 00000000..8b336eaf --- /dev/null +++ b/packages/core/src/engagement/chat/drizzle/migrations/meta/0004_snapshot.json @@ -0,0 +1,666 @@ +{ + "id": "5baef465-6157-424e-a7bd-c5ee7ada8973", + "prevId": "79e5eb46-0b0f-4006-8e14-b713be88acfe", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.chat_message": { + "name": "chat_message", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "room_id": { + "name": "room_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "chat_message_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_deleted": { + "name": "is_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_msg_room_id_created_at_idx": { + "name": "chat_msg_room_id_created_at_idx", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_msg_created_at_idx": { + "name": "chat_msg_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_message_room_id_chat_room_id_fk": { + "name": "chat_message_room_id_chat_room_id_fk", + "tableFrom": "chat_message", + "tableTo": "chat_room", + "columnsFrom": ["room_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_room": { + "name": "chat_room", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "chat_room_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'games-sports'" + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "join_code": { + "name": "join_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chat_room_slug_key": { + "name": "chat_room_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_room_join_code_key": { + "name": "chat_room_join_code_key", + "columns": [ + { + "expression": "join_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_room_deleted_at_idx": { + "name": "chat_room_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_room_creator_public_deleted_at_idx": { + "name": "chat_room_creator_public_deleted_at_idx", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_public", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_room_ban": { + "name": "chat_room_ban", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "room_id": { + "name": "room_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "banned_by": { + "name": "banned_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_room_ban_room_user_key": { + "name": "chat_room_ban_room_user_key", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_room_ban_room_idx": { + "name": "chat_room_ban_room_idx", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_room_ban_room_id_chat_room_id_fk": { + "name": "chat_room_ban_room_id_chat_room_id_fk", + "tableFrom": "chat_room_ban", + "tableTo": "chat_room", + "columnsFrom": ["room_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_room_member": { + "name": "chat_room_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "room_id": { + "name": "room_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "chat_room_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_room_member_room_user_key": { + "name": "chat_room_member_room_user_key", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_room_member_room_idx": { + "name": "chat_room_member_room_idx", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_room_member_user_idx": { + "name": "chat_room_member_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_room_member_room_id_chat_room_id_fk": { + "name": "chat_room_member_room_id_chat_room_id_fk", + "tableFrom": "chat_room_member", + "tableTo": "chat_room", + "columnsFrom": ["room_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_user_block": { + "name": "chat_user_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "blocker_id": { + "name": "blocker_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "blocked_id": { + "name": "blocked_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chat_user_block_pair_key": { + "name": "chat_user_block_pair_key", + "columns": [ + { + "expression": "blocker_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "blocked_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat_user_block\".\"removed_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_user_block_blocker_idx": { + "name": "chat_user_block_blocker_idx", + "columns": [ + { + "expression": "blocker_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_user_ignore": { + "name": "chat_user_ignore", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ignorer_id": { + "name": "ignorer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ignored_id": { + "name": "ignored_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chat_user_ignore_pair_key": { + "name": "chat_user_ignore_pair_key", + "columns": [ + { + "expression": "ignorer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ignored_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat_user_ignore\".\"removed_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_user_ignore_ignorer_idx": { + "name": "chat_user_ignore_ignorer_idx", + "columns": [ + { + "expression": "ignorer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.chat_message_type": { + "name": "chat_message_type", + "schema": "public", + "values": ["user", "system"] + }, + "public.chat_room_category": { + "name": "chat_room_category", + "schema": "public", + "values": ["games-sports", "regions", "languages", "private-channels"] + }, + "public.chat_room_role": { + "name": "chat_room_role", + "schema": "public", + "values": ["member", "moderator"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/core/src/engagement/chat/drizzle/migrations/meta/_journal.json b/packages/core/src/engagement/chat/drizzle/migrations/meta/_journal.json index 5651972f..bc3bb83c 100644 --- a/packages/core/src/engagement/chat/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/engagement/chat/drizzle/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1785446950360, "tag": "0003_stormy_king_bedlam", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1785847639753, + "tag": "0004_panoramic_robin_chapel", + "breakpoints": true } ] } diff --git a/packages/core/src/engagement/chat/router/index.ts b/packages/core/src/engagement/chat/router/index.ts index 9ffd315d..3de11bbd 100644 --- a/packages/core/src/engagement/chat/router/index.ts +++ b/packages/core/src/engagement/chat/router/index.ts @@ -180,8 +180,8 @@ export function createChatRouter({ return chatService.getOnlineCount(roomId); }), - listBlockedUsers: os.listBlockedUsers.handler(({ context }) => - chatService.listBlockedUsers(getUserId(context)), + listBlockedUsers: os.listBlockedUsers.handler(({ input, context }) => + chatService.listBlockedUsers({ blockerId: getUserId(context), ...input }), ), blockUser: os.blockUser.handler(({ input, context }) => { @@ -195,8 +195,8 @@ export function createChatRouter({ return chatService.unblockUser(getUserId(context), input.blockedId, context.clientMeta); }), - listIgnoredUsers: os.listIgnoredUsers.handler(({ context }) => - chatService.listIgnoredUsers(getUserId(context)), + listIgnoredUsers: os.listIgnoredUsers.handler(({ input, context }) => + chatService.listIgnoredUsers({ ignorerId: getUserId(context), ...input }), ), ignoreUser: os.ignoreUser.handler(({ input, context }) => { @@ -323,5 +323,15 @@ export function createChatRouter({ chatService.deleteRoom(input.id, userId, { ip, userAgent }), ); }), + + adminListBlockedUsers: os.adminListBlockedUsers.handler(async ({ input, context }) => { + await adminGuard.assert(context, 'chat-moderation', 'view'); + return chatService.adminListBlockedUsers(input); + }), + + adminListIgnoredUsers: os.adminListIgnoredUsers.handler(async ({ input, context }) => { + await adminGuard.assert(context, 'chat-moderation', 'view'); + return chatService.adminListIgnoredUsers(input); + }), }); } diff --git a/packages/core/src/engagement/chat/schema/index.ts b/packages/core/src/engagement/chat/schema/index.ts index a6b69828..ffdeff04 100644 --- a/packages/core/src/engagement/chat/schema/index.ts +++ b/packages/core/src/engagement/chat/schema/index.ts @@ -1,3 +1,4 @@ +import { sql } from 'drizzle-orm'; import { pgTable, pgEnum, @@ -60,6 +61,10 @@ export const chatMessage = pgTable( // A directional mute: `blockerId` no longer sees messages from `blockedId`. // Scoped to the blocker only - the blocked player is unaffected (ABC-45 AC11). +// Soft-deleted: unblock sets removedAt rather than deleting the row, so the pair's +// block history survives. The unique pair index is partial (active rows only) so a +// block -> unblock -> re-block cycle inserts a new active row instead of conflicting +// with the removed one - see chat.service.ts blockUser/unblockUser. export const chatUserBlock = pgTable( 'chat_user_block', { @@ -67,16 +72,20 @@ export const chatUserBlock = pgTable( blockerId: uuid().notNull(), blockedId: uuid().notNull(), createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), + removedAt: timestamp({ withTimezone: true }), }, (t) => [ - uniqueIndex('chat_user_block_pair_key').on(t.blockerId, t.blockedId), + uniqueIndex('chat_user_block_pair_key') + .on(t.blockerId, t.blockedId) + .where(sql`${t.removedAt} IS NULL`), index('chat_user_block_blocker_idx').on(t.blockerId), ], ); // A directional soft-mute: `ignorerId` no longer sees messages from `ignoredId`. // A separate relationship from chatUserBlock (not an alias) - same message-hiding -// effect for now, but block vs ignore may diverge further later. +// effect for now, but block vs ignore may diverge further later. Soft-deleted the +// same way as chatUserBlock - see the comment there. export const chatUserIgnore = pgTable( 'chat_user_ignore', { @@ -84,9 +93,12 @@ export const chatUserIgnore = pgTable( ignorerId: uuid().notNull(), ignoredId: uuid().notNull(), createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), + removedAt: timestamp({ withTimezone: true }), }, (t) => [ - uniqueIndex('chat_user_ignore_pair_key').on(t.ignorerId, t.ignoredId), + uniqueIndex('chat_user_ignore_pair_key') + .on(t.ignorerId, t.ignoredId) + .where(sql`${t.removedAt} IS NULL`), index('chat_user_ignore_ignorer_idx').on(t.ignorerId), ], ); diff --git a/packages/core/src/engagement/chat/service/chat.service.ts b/packages/core/src/engagement/chat/service/chat.service.ts index a2e06e3a..93347000 100644 --- a/packages/core/src/engagement/chat/service/chat.service.ts +++ b/packages/core/src/engagement/chat/service/chat.service.ts @@ -34,6 +34,8 @@ import { } from '../schema/index.js'; import type { AdminRoomSortBy, + BlockedUserSortBy, + IgnoredUserSortBy, ChatRoom, ChatMessage, ChatRoomCategory, @@ -124,6 +126,15 @@ function toMessage(record: typeof chatMessage.$inferSelect) { return serializeRow(record, { dateFields: ['createdAt'] }); } +const BLOCKED_USER_SORT_COLUMNS = { createdAt: chatUserBlock.createdAt } as const satisfies Record< + BlockedUserSortBy, + unknown +>; + +const IGNORED_USER_SORT_COLUMNS = { + createdAt: chatUserIgnore.createdAt, +} as const satisfies Record; + function generateJoinCode(): string { return Array.from({ length: JOIN_CODE_LENGTH }, () => { const ch = JOIN_CODE_ALPHABET[randomInt(0, JOIN_CODE_ALPHABET.length)]; @@ -205,7 +216,7 @@ export class ChatService { const rows = await this.drizzle.db .select({ blockedId: chatUserBlock.blockedId }) .from(chatUserBlock) - .where(eq(chatUserBlock.blockerId, viewerId)); + .where(and(eq(chatUserBlock.blockerId, viewerId), isNull(chatUserBlock.removedAt))); return new Set(rows.map((r) => r.blockedId)); } @@ -213,7 +224,7 @@ export class ChatService { const rows = await this.drizzle.db .select({ ignoredId: chatUserIgnore.ignoredId }) .from(chatUserIgnore) - .where(eq(chatUserIgnore.ignorerId, viewerId)); + .where(and(eq(chatUserIgnore.ignorerId, viewerId), isNull(chatUserIgnore.removedAt))); return new Set(rows.map((r) => r.ignoredId)); } @@ -459,13 +470,75 @@ export class ChatService { return message; } - async listBlockedUsers(blockerId: User['id']) { - const rows = await this.drizzle.db - .select({ blockedId: chatUserBlock.blockedId, createdAt: chatUserBlock.createdAt }) - .from(chatUserBlock) - .where(eq(chatUserBlock.blockerId, blockerId)) - .orderBy(desc(chatUserBlock.createdAt)); - return rows.map((r) => serializeRow(r, { dateFields: ['createdAt'] })); + async listBlockedUsers({ + blockerId, + page, + limit, + sortBy, + sortOrder, + }: { + blockerId: User['id']; + page: number; + limit: number; + sortBy: BlockedUserSortBy; + sortOrder: SortOrder; + }) { + const dir = sortOrder === 'asc' ? asc : desc; + const col = BLOCKED_USER_SORT_COLUMNS[sortBy]; + const where = and(eq(chatUserBlock.blockerId, blockerId), isNull(chatUserBlock.removedAt)); + const [rows, [{ n }]] = await Promise.all([ + this.drizzle.db + .select({ blockedId: chatUserBlock.blockedId, createdAt: chatUserBlock.createdAt }) + .from(chatUserBlock) + .where(where) + .orderBy(dir(col)) + .limit(limit) + .offset(pageToOffset(page, limit)), + this.drizzle.db.select({ n: count() }).from(chatUserBlock).where(where), + ]); + return { + items: rows.map((r) => serializeRow(r, { dateFields: ['createdAt'] })), + total: Number(n), + page, + limit, + }; + } + + /** Backoffice-only, site-wide: every active block relationship, not just the caller's. */ + async adminListBlockedUsers({ + page, + limit, + sortBy, + sortOrder, + }: { + page: number; + limit: number; + sortBy: BlockedUserSortBy; + sortOrder: SortOrder; + }) { + const dir = sortOrder === 'asc' ? asc : desc; + const col = BLOCKED_USER_SORT_COLUMNS[sortBy]; + const where = isNull(chatUserBlock.removedAt); + const [rows, [{ n }]] = await Promise.all([ + this.drizzle.db + .select({ + blockerId: chatUserBlock.blockerId, + blockedId: chatUserBlock.blockedId, + createdAt: chatUserBlock.createdAt, + }) + .from(chatUserBlock) + .where(where) + .orderBy(dir(col)) + .limit(limit) + .offset(pageToOffset(page, limit)), + this.drizzle.db.select({ n: count() }).from(chatUserBlock).where(where), + ]); + return { + items: rows.map((r) => serializeRow(r, { dateFields: ['createdAt'] })), + total: Number(n), + page, + limit, + }; } async blockUser(blockerId: User['id'], blockedId: User['id'], meta?: ClientMeta) { @@ -473,11 +546,15 @@ export class ChatService { throw new ChatSelfBlockError(); } - // Idempotent: re-blocking is a no-op, so only the first block emits an event. + // Idempotent: re-blocking while already (actively) blocked is a no-op, so only + // the first block emits an event. The pair unique index is partial (removedAt + // IS NULL), so a block after a prior unblock conflicts with nothing and inserts + // a fresh active row - the removed row stays as history. No explicit conflict + // target: this table has exactly one unique constraint (the partial pair index). const inserted = await this.drizzle.db .insert(chatUserBlock) .values({ blockerId, blockedId }) - .onConflictDoNothing({ target: [chatUserBlock.blockerId, chatUserBlock.blockedId] }) + .onConflictDoNothing() .returning(); if (inserted.length > 0) { @@ -492,9 +569,18 @@ export class ChatService { } async unblockUser(blockerId: User['id'], blockedId: User['id'], meta?: ClientMeta) { + // Soft-delete: the partial unique index guarantees at most one active (removedAt + // IS NULL) row per pair, so this is that row, or no-op if already unblocked. const removed = await this.drizzle.db - .delete(chatUserBlock) - .where(and(eq(chatUserBlock.blockerId, blockerId), eq(chatUserBlock.blockedId, blockedId))) + .update(chatUserBlock) + .set({ removedAt: new Date() }) + .where( + and( + eq(chatUserBlock.blockerId, blockerId), + eq(chatUserBlock.blockedId, blockedId), + isNull(chatUserBlock.removedAt), + ), + ) .returning(); if (removed.length > 0) { @@ -508,13 +594,75 @@ export class ChatService { return { success: true } as const; } - async listIgnoredUsers(ignorerId: User['id']) { - const rows = await this.drizzle.db - .select({ ignoredId: chatUserIgnore.ignoredId, createdAt: chatUserIgnore.createdAt }) - .from(chatUserIgnore) - .where(eq(chatUserIgnore.ignorerId, ignorerId)) - .orderBy(desc(chatUserIgnore.createdAt)); - return rows.map((r) => serializeRow(r, { dateFields: ['createdAt'] })); + async listIgnoredUsers({ + ignorerId, + page, + limit, + sortBy, + sortOrder, + }: { + ignorerId: User['id']; + page: number; + limit: number; + sortBy: IgnoredUserSortBy; + sortOrder: SortOrder; + }) { + const dir = sortOrder === 'asc' ? asc : desc; + const col = IGNORED_USER_SORT_COLUMNS[sortBy]; + const where = and(eq(chatUserIgnore.ignorerId, ignorerId), isNull(chatUserIgnore.removedAt)); + const [rows, [{ n }]] = await Promise.all([ + this.drizzle.db + .select({ ignoredId: chatUserIgnore.ignoredId, createdAt: chatUserIgnore.createdAt }) + .from(chatUserIgnore) + .where(where) + .orderBy(dir(col)) + .limit(limit) + .offset(pageToOffset(page, limit)), + this.drizzle.db.select({ n: count() }).from(chatUserIgnore).where(where), + ]); + return { + items: rows.map((r) => serializeRow(r, { dateFields: ['createdAt'] })), + total: Number(n), + page, + limit, + }; + } + + /** Backoffice-only, site-wide: every active ignore relationship, not just the caller's. */ + async adminListIgnoredUsers({ + page, + limit, + sortBy, + sortOrder, + }: { + page: number; + limit: number; + sortBy: IgnoredUserSortBy; + sortOrder: SortOrder; + }) { + const dir = sortOrder === 'asc' ? asc : desc; + const col = IGNORED_USER_SORT_COLUMNS[sortBy]; + const where = isNull(chatUserIgnore.removedAt); + const [rows, [{ n }]] = await Promise.all([ + this.drizzle.db + .select({ + ignorerId: chatUserIgnore.ignorerId, + ignoredId: chatUserIgnore.ignoredId, + createdAt: chatUserIgnore.createdAt, + }) + .from(chatUserIgnore) + .where(where) + .orderBy(dir(col)) + .limit(limit) + .offset(pageToOffset(page, limit)), + this.drizzle.db.select({ n: count() }).from(chatUserIgnore).where(where), + ]); + return { + items: rows.map((r) => serializeRow(r, { dateFields: ['createdAt'] })), + total: Number(n), + page, + limit, + }; } async ignoreUser(ignorerId: User['id'], ignoredId: User['id'], meta?: ClientMeta) { @@ -522,11 +670,11 @@ export class ChatService { throw new ChatSelfIgnoreError(); } - // Idempotent: re-ignoring is a no-op, so only the first ignore emits an event. + // Idempotent + soft-delete-aware - see blockUser's comment, same pattern. const inserted = await this.drizzle.db .insert(chatUserIgnore) .values({ ignorerId, ignoredId }) - .onConflictDoNothing({ target: [chatUserIgnore.ignorerId, chatUserIgnore.ignoredId] }) + .onConflictDoNothing() .returning(); if (inserted.length > 0) { @@ -541,9 +689,17 @@ export class ChatService { } async unignoreUser(ignorerId: User['id'], ignoredId: User['id'], meta?: ClientMeta) { + // Soft-delete - see unblockUser's comment, same pattern. const removed = await this.drizzle.db - .delete(chatUserIgnore) - .where(and(eq(chatUserIgnore.ignorerId, ignorerId), eq(chatUserIgnore.ignoredId, ignoredId))) + .update(chatUserIgnore) + .set({ removedAt: new Date() }) + .where( + and( + eq(chatUserIgnore.ignorerId, ignorerId), + eq(chatUserIgnore.ignoredId, ignoredId), + isNull(chatUserIgnore.removedAt), + ), + ) .returning(); if (removed.length > 0) { diff --git a/packages/core/src/engagement/contracts.ts b/packages/core/src/engagement/contracts.ts index c4131e72..84e52065 100644 --- a/packages/core/src/engagement/contracts.ts +++ b/packages/core/src/engagement/contracts.ts @@ -1,3 +1,4 @@ export * as chat from './chat/contract/index.js'; export * as chatCommands from './chat-commands/contract/index.js'; export * as notifications from './notifications/contract/index.js'; +export * as socialTransfers from './social-transfers/contract/index.js'; diff --git a/packages/core/src/engagement/index.ts b/packages/core/src/engagement/index.ts index c4131e72..84e52065 100644 --- a/packages/core/src/engagement/index.ts +++ b/packages/core/src/engagement/index.ts @@ -1,3 +1,4 @@ export * as chat from './chat/contract/index.js'; export * as chatCommands from './chat-commands/contract/index.js'; export * as notifications from './notifications/contract/index.js'; +export * as socialTransfers from './social-transfers/contract/index.js'; diff --git a/packages/core/src/engagement/server.ts b/packages/core/src/engagement/server.ts index 9f3d87b5..8b3b61ee 100644 --- a/packages/core/src/engagement/server.ts +++ b/packages/core/src/engagement/server.ts @@ -1,3 +1,4 @@ export { default as chatPlugin } from './chat/plugin.js'; export { default as chatCommandsPlugin } from './chat-commands/plugin.js'; export { default as notificationsPlugin } from './notifications/plugin.js'; +export { default as socialTransfersPlugin } from './social-transfers/plugin.js'; diff --git a/packages/core/src/engagement/social-transfers/AGENTS.md b/packages/core/src/engagement/social-transfers/AGENTS.md new file mode 100644 index 00000000..45cfb02b --- /dev/null +++ b/packages/core/src/engagement/social-transfers/AGENTS.md @@ -0,0 +1,181 @@ +# social-transfers + +Owns all player-to-player money movement that flows through the chat command surface: `/gift` (claimable gift card), `/rain` (split among online room members), `/donate` (direct tip to a known username). Ported from `chat-commands`, which used to implement all three directly - see that module's AGENTS.md for what moved and why. `sendDonate` is this module's own oRPC operation; gift send/claim and rain send are exposed to `chat-commands` only through the `GIFT_COMMANDS`/`RAIN_COMMANDS` command ports - `chat-commands`' `postGift`/`claimGift`/`postRain` are pure delegation, this module owns every part of the mechanics, including posting and publishing the resulting chat message. + +## Global chat uses the GLOBAL_CHAT_ROOM_ID sentinel on the wire, `null` internally + +`/gift`, `/rain`, and `/donate` can all target global chat, not just a room. The wire contract +(`PostGiftInputSchema`/`PostRainInputSchema` in `chat-commands/contract/`, `SendDonateInputSchema` +here) types `roomId` as `ChatRoomIdSchema` (`packages/core/src/contracts/schemas/chat-command.ts`): +a real room UUID, or the literal string `GLOBAL_CHAT_ROOM_ID` (`'__global'`) - callers never send a +raw `null`. The schema's `.transform()` normalizes `'__global'` to `null` before the handler ever +runs, so every internal type (`SendGiftArgs`/`SendRainArgs`/`DonateArgs.roomId`, `player_gift.roomId`, +`player_rain.roomId`) is `Uuid | null`, `null` meaning global chat - the same representation +`chatChannel()` and the `chat` module's own `roomId: null` already use. `verifyRoomAccessIfNeeded` +skips room-membership verification entirely when `roomId` is `null` (global chat has no membership +gate). Never reintroduce a raw nullable `roomId` on a wire schema here - always `ChatRoomIdSchema`, +so there is exactly one way to address global chat at the HTTP boundary. + +## This module never queries chat's presence tracking - it takes recipient ids as input + +`/rain` needs to know who is online, but that lookup does NOT happen here. `chat-commands` already depends on this module (for `GIFT_COMMANDS`), so this module depending back on `chat-commands` - or on `chat`'s `CHAT_REALTIME_TRANSPORT.getOnlineUserIds` specifically for presence - would create an import cycle. Instead `RAIN_COMMANDS.sendRain`'s input carries `onlineUserIds: Uuid[]`, resolved by `chat-commands` (which owns presence for the whole chat-command surface) BEFORE it calls this port. `doSendRain` does exactly what it always did with that list - filter out the actor, shuffle, cap to `recipientCount`, report `no_online_users` if nothing is left - it just no longer fetches the list itself. See `chat-commands/AGENTS.md` for the full reasoning. + +This module still depends on `chat` directly for gift/rain/donate/claim: `CHAT_SYSTEM_WRITER` (each +command's system-message insert runs inside ITS OWN `db.transaction`, atomic with the wallet move), +`CHAT_ROOM_ACCESS` (room-membership verification before any money moves, all three commands), and +`CHAT_REALTIME_TRANSPORT.publish` (the post-commit realtime push for gift/rain/donate/claim - see +"Publish-after-commit"). Only rain's presence QUERY moved to the caller; everything else threaded +through a transaction stays here. + +## Idempotency for money-moving commands (gift/rain/donate) + +All three require a client `idempotencyKey` (uuid). The `CACHE` port reserves +`chat-command:idempotency:{actorId}:{commandType}:{idempotencyKey}` atomically with a five-minute +TTL (the key namespace stays `chat-command:*` even though the logic now lives here - it is an +internal Redis key, not part of any public contract), stores the full-request fingerprint, and then +stores the completed `ChatSystemMessage` under the same key. A matching fingerprint replays without +another wallet call; a different fingerprint throws `ChatCommandIdempotencyKeyReuseError`; a +concurrent in-flight request throws `ConcurrentCommandReplayError`. Failed money transactions +release the reservation for all three commands - the reservation guard wraps the whole +`db.transaction(...)` call, so a failure anywhere inside it (wallet debit/credit, the system-message +insert, the audit row) releases the reservation and a retry is free to try again. The cache must +provide atomic `setIfAbsent` - a plain get-then-set is not safe for money. There is NO separate DB +table for idempotency, it's Redis/CACHE-only. A rain replay does not re-validate or re-use +`onlineUserIds` at all - the caller resolves presence unconditionally before calling in, even on a +request that turns out to be a replay (a minor, accepted trade-off: one extra +`CHAT_REALTIME_TRANSPORT` call on a replay, in exchange for social-transfers never touching +presence). + +## Publish-after-commit for gift/rain/donate/claim + +`ChatSystemWriter.postSystemMessage` only auto-publishes to realtime when it owns the write (no +`tx` argument). `doSendGift`/`doSendRain`/`doSendDonate`/`doClaimGift` all pass their own `tx`, so +they own the commit boundary and must call `this.transport.publish(chatChannel(...), msg)` +themselves, AFTER their `db.transaction(...)` call resolves - never before, or a client could see a +gift/rain/donate message for money that was never actually moved (transaction rolled back after the +publish). Money movement and the system-message row commit atomically together in every one of the +four commands; there is no exception. + +## Claimable gift mechanic + +`/gift ` is a two-step flow: the sender is debited immediately and a `player_gift` row +(status: unclaimed) is created atomically with the system message. Any other player calls +`GIFT_COMMANDS.claimGift` (via `chat-commands`' `POST /chat-command/gift/:id/claim`) to win the +credit. `doClaimGift` opens its transaction with `SELECT ... FOR UPDATE` on the target `player_gift` +row (same pattern as wallet's guarded debit) to serialize concurrent claims, THEN checks +self-claim/already-claimed against the locked read, THEN does a guarded conditional +`UPDATE ... WHERE claimed_by IS NULL RETURNING *` as defense-in-depth on top of the lock - first +caller wins, zero balance goes unreturned. Realtime push fires on claim via +`CHAT_REALTIME_TRANSPORT.publish(chatChannel(roomId), ...)`. + +`playerGift.messageId` is a plain UUID with no FK - cross-module boundary rule applies. + +Reading a gift's current state is a separate, read-only capability: `GIFT_COMMANDS.getGift` (via +`chat-commands`' `GET /chat-command/gift/{id}`) returns a `GiftSnapshot` (claimed/unclaimed, +claimed-by-whom) and enforces the same room-membership check as `claimGift` - a viewer must be a +member of the gift's room to poll its status. Unlike `doClaimGift`, `doGetGift` takes no row lock +(`SELECT` without `FOR UPDATE`): it's read-only, nothing to serialize against. + +### The old `chat_gift` table is a deliberate, deferred follow-up + +`player_gift` is the successor to chat-commands' `chat_gift` table (same shape). `chat_gift` and its +migration are left untouched in `chat-commands` - there may be prod data in it and no migration/ +backfill plan has been decided. New gift rows go to `player_gift` only; nothing reads or writes +`chat_gift` anymore. Reconciling/retiring the old table is out of scope until that plan exists. + +## Rain has a remainder - debit the distributed amount, not the typed amount + +`/rain ` splits `floor(amount / recipientCount)` to each recipient - `perRecipient * +recipientCount` can be LESS than the player-typed `amount` (eg `10.99` split 10 ways credits +`10.00` total). `doSendRain` debits `totalDistributed` (`floor(amount/n)*n`, computed in the same +SQL statement as `perRecipient`), never the raw `input.amount` - otherwise the undistributed +remainder is silently taken from the sender and never credited anywhere. The system-message +metadata (built by `doSendRain` itself), audit `after.amount`, the persisted +`player_rain.amount`, and the `chat.rain.distributed` event's `totalAmount` all report +`totalDistributed` too, since that is what actually left the sender's wallet. The pre-transaction +`maxAmount`/`minAmount`/`amountUnits` limit checks still validate against the player-typed +`input.amount` - that happens before the split is known and is correct as-is. `player_rain` is a new +header row per rain event (nothing was persisted before this module existed); `player_rain_receiver` +has one row per recipient, in-module FK to `player_rain.id`. + +## Exact username resolution for `/donate` + +`/donate` resolves an EXACT, already-known username (typed in full or picked from autocomplete, +never a partial search term) via `resolveExactPlayer`, which calls +`ADMIN_USER_DIRECTORY.getPlayerByUsername` - a real exact, case-insensitive match. It does NOT use +`findPlayerIds` (that method is a capped, unordered `ILIKE '%query%'` substring search meant for +autocomplete-style fuzzy search): a short/common username can substring-collide with more than the +20-row cap of unrelated accounts, so the real target could fall outside the first 20 rows Postgres +happens to return and get a false `ChatPlayerNotFoundError`. `player_donate` is a new table (nothing +was persisted before this module existed - donate previously only moved wallet balances and posted +a chat message). + +## Command config gate - read-only cross-module schema import + +Each of gift/rain/donate is gated by a row in `chat_command_config` (owned by `chat-commands`, +keyed by command type) - a missing or disabled row throws `CommandDisabledError`; `config.minAmount`/ +`config.maxAmount`/`config.maxRecipients` (rain only) are enforced from the SAME row. This module +imports the `chatCommandConfig` table definition from `@openora/core/engagement/schema/chat-commands` +(the sanctioned read-only cross-module `/schema` import - `db-conventions`/`clean-architecture`) and +queries it with its own `DRIZZLE` connection (one shared Postgres database). It never writes that +table - toggling a command's config is done by `chat-commands`' own `adminUpdateCommand` +(`PATCH /backoffice/chat-command/commands/{key}`, AdminGuard-gated on the `chat-command` resource); +see `chat-commands/AGENTS.md`. + +## GIFT_COMMANDS / RAIN_COMMANDS ports - result type, not throw, across the module boundary + +`sendGift`/`claimGift`/`getGift`/`sendRain` never throw for an EXPECTED failure (disabled, +insufficient balance, limits, no online users, too many recipients, idempotency reuse/replay, room +membership, gift not found/claimed/self-claim) - they return `{ ok: false, reason }` instead, because +`chat-commands`' router can't `instanceof`-match an error class defined in this module without a +forbidden cross-module internals import. `toSendGiftResult`/`toClaimGiftResult`/`toGetGiftResult`/ +`toSendRainResult` do this translation once, at the very edge of each port implementation; only a +genuinely unexpected error still throws across the port (mirrors `WALLET_COMMANDS`' `{ ok: false, +... }` outcome pattern - ADR-0017). `sendDonate` is NOT behind a port (it's this module's own route, +`chat-commands` never calls it), so it stays throw-based like every other module's router-facing +service method. + +## Ports consumed + +- `CHAT_SYSTEM_WRITER` - posts system messages into the chat stream for gift/rain/donate, inside + each command's own transaction (bound by `chat`). +- `CACHE` - atomic Redis-backed idempotency reservation and short-lived replay result storage. +- `WALLET_COMMANDS` - debits the actor and credits recipient(s) within a single transaction; money + never flows over events. +- `ADMIN_USER_DIRECTORY` - `lookupPlayers` for batch profile resolution, `getPlayerByUsername` for + exact-match resolution of an already-known username (`/donate`). +- `AUDIT_WRITER` - transactional `recordInTransaction()` for gift/rain/donate money paths, so the + audit row commits or rolls back with the wallet move. +- `CHAT_REALTIME_TRANSPORT` - `publish(channel, event)` for gift-claimed push and gift/rain/donate + system messages, AFTER each transaction commits. The chat-scoped token, not the generic + `REALTIME_TRANSPORT` - this module publishes on the same `chat:*` channels chat itself uses. + `getOnlineUserIds` is NOT used here - see "This module never queries chat's presence tracking" + above. +- `CHAT_ROOM_ACCESS` - `verifyRoomAccess` before any money moves into a room. + +## Invariants + +- Gift send: debit + `playerGift` insert + system message are atomic in one transaction. + `messageId` is back-filled in the same transaction after `postSystemMessage` returns. +- Gift claim: the row is locked with `SELECT ... FOR UPDATE` at the top of the transaction, then the + `UPDATE ... WHERE claimed_by IS NULL` runs as a guarded conditional write on top of that lock. + Self-claim and already-claimed are both rejected against the locked read, before the update. +- Rain recipients are capped by `config.maxRecipients` (default 50) and filtered to exclude the actor + from the caller-supplied `onlineUserIds` list. +- Gift/rain/donate money movement is fully transactional: debit + credits + system message + + persisted row(s) happen atomically, in every one of the three commands. +- Money-moving audit rows are written through the same transaction as the debit/credit and the + system message. +- `mapConcurrent` (limit 10) is used for rain credits - never `Promise.all` on an unbounded + recipient list. + +## Don't + +- Don't write to `chat_gift` (chat-commands' table) - it is frozen, not extended. +- Don't add a DB-backed idempotency table for gift/rain/donate - CACHE/Redis is the single source + of truth for replay guards here. +- Don't call `CHAT_REALTIME_TRANSPORT.getOnlineUserIds` (or otherwise reach for chat presence) from + this module - `chat-commands` resolves recipients and passes them into `RAIN_COMMANDS.sendRain`. +- Don't split rain's chat-message write out of its own transaction (eg via a callback owned by + another module) - `doSendRain` posts the system message and persists the money move atomically, + the same as `doSendGift`/`doSendDonate`. diff --git a/packages/core/src/engagement/social-transfers/contract/index.ts b/packages/core/src/engagement/social-transfers/contract/index.ts new file mode 100644 index 00000000..a32849de --- /dev/null +++ b/packages/core/src/engagement/social-transfers/contract/index.ts @@ -0,0 +1,29 @@ +import { oc } from '@orpc/contract'; +import * as z from 'zod'; +import { + UuidSchema, + MoneyAmountSchema, + SystemChatMessageSchema, + ChatRoomIdSchema, +} from '@openora/core/contracts'; + +export { SystemChatMessageSchema }; + +export const SendDonateInputSchema = z.object({ + targetUsername: z.string().min(1), + amount: MoneyAmountSchema, + roomId: ChatRoomIdSchema, + idempotencyKey: UuidSchema, +}); +export type SendDonateInput = z.infer; + +// sendGift/claimGift/sendRain are NOT public routes here - they are consumed by +// `chat-commands` exclusively through the GIFT_COMMANDS/RAIN_COMMANDS ports (see +// AGENTS.md). Only `sendDonate` (no online-presence dependency) stays this +// module's own oRPC operation. +export const socialTransfersContract = { + sendDonate: oc + .route({ method: 'POST', path: '/social-transfers/donate' }) + .input(SendDonateInputSchema) + .output(SystemChatMessageSchema), +}; diff --git a/packages/core/src/engagement/social-transfers/drizzle.config.ts b/packages/core/src/engagement/social-transfers/drizzle.config.ts new file mode 100644 index 00000000..cc1efabd --- /dev/null +++ b/packages/core/src/engagement/social-transfers/drizzle.config.ts @@ -0,0 +1,22 @@ +import 'dotenv/config'; +import { defineConfig } from 'drizzle-kit'; + +// Own migration history + tracking table, co-located with the module's schema. +// One DB shared with sibling modules, but one journal per module so a module can +// be extracted to its own package without history surgery. See ADR-0020/0027. +export default defineConfig({ + dialect: 'postgresql', + casing: 'snake_case', + schema: ['./schema/index.ts'], + out: './drizzle/migrations', + migrations: { + table: '__drizzle_migrations_social_transfers', + schema: 'drizzle', + }, + dbCredentials: { + url: + process.env['DATABASE_ADMIN_URL'] ?? + process.env['DATABASE_URL'] ?? + 'postgresql://postgres:postgres@localhost:5432/oss_igaming', + }, +}); diff --git a/packages/core/src/engagement/social-transfers/drizzle/migrations/0000_clammy_rattler.sql b/packages/core/src/engagement/social-transfers/drizzle/migrations/0000_clammy_rattler.sql new file mode 100644 index 00000000..4b0e5932 --- /dev/null +++ b/packages/core/src/engagement/social-transfers/drizzle/migrations/0000_clammy_rattler.sql @@ -0,0 +1,46 @@ +CREATE TABLE "player_donate" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "sender_id" uuid NOT NULL, + "sender_username" text NOT NULL, + "recipient_id" uuid NOT NULL, + "recipient_username" text NOT NULL, + "amount" numeric(18, 8) NOT NULL, + "currency" text NOT NULL, + "room_id" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "player_gift" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "message_id" uuid NOT NULL, + "sender_id" uuid NOT NULL, + "sender_username" text NOT NULL, + "amount" numeric(18, 8) NOT NULL, + "currency" text NOT NULL, + "room_id" uuid, + "claimed_by" uuid, + "claimed_by_username" text, + "claimed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "player_rain" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "sender_id" uuid NOT NULL, + "amount" numeric(18, 8) NOT NULL, + "per_recipient" numeric(18, 8) NOT NULL, + "currency" text NOT NULL, + "room_id" uuid, + "recipient_count" integer NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "player_rain_receiver" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "rain_id" uuid NOT NULL, + "recipient_id" uuid NOT NULL, + "amount" numeric(18, 8) NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "player_rain_receiver" ADD CONSTRAINT "player_rain_receiver_rain_id_player_rain_id_fk" FOREIGN KEY ("rain_id") REFERENCES "public"."player_rain"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/packages/core/src/engagement/social-transfers/drizzle/migrations/meta/0000_snapshot.json b/packages/core/src/engagement/social-transfers/drizzle/migrations/meta/0000_snapshot.json new file mode 100644 index 00000000..ea6d76bc --- /dev/null +++ b/packages/core/src/engagement/social-transfers/drizzle/migrations/meta/0000_snapshot.json @@ -0,0 +1,287 @@ +{ + "id": "f2a9b9cb-6eba-4596-960b-1bbc7809b899", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.player_donate": { + "name": "player_donate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "sender_id": { + "name": "sender_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sender_username": { + "name": "sender_username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_id": { + "name": "recipient_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recipient_username": { + "name": "recipient_username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(18, 8)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "room_id": { + "name": "room_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.player_gift": { + "name": "player_gift", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "message_id": { + "name": "message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sender_username": { + "name": "sender_username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(18, 8)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "room_id": { + "name": "room_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "claimed_by_username": { + "name": "claimed_by_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.player_rain": { + "name": "player_rain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "sender_id": { + "name": "sender_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(18, 8)", + "primaryKey": false, + "notNull": true + }, + "per_recipient": { + "name": "per_recipient", + "type": "numeric(18, 8)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "room_id": { + "name": "room_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "recipient_count": { + "name": "recipient_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.player_rain_receiver": { + "name": "player_rain_receiver", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "rain_id": { + "name": "rain_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recipient_id": { + "name": "recipient_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(18, 8)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "player_rain_receiver_rain_id_player_rain_id_fk": { + "name": "player_rain_receiver_rain_id_player_rain_id_fk", + "tableFrom": "player_rain_receiver", + "tableTo": "player_rain", + "columnsFrom": ["rain_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/core/src/engagement/social-transfers/drizzle/migrations/meta/_journal.json b/packages/core/src/engagement/social-transfers/drizzle/migrations/meta/_journal.json new file mode 100644 index 00000000..2f2c1f2d --- /dev/null +++ b/packages/core/src/engagement/social-transfers/drizzle/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1785845454833, + "tag": "0000_clammy_rattler", + "breakpoints": true + } + ] +} diff --git a/packages/core/src/engagement/social-transfers/index.ts b/packages/core/src/engagement/social-transfers/index.ts new file mode 100644 index 00000000..79898325 --- /dev/null +++ b/packages/core/src/engagement/social-transfers/index.ts @@ -0,0 +1,5 @@ +// Internal barrel of the SocialTransfers module. The public consumer surface is the +// domain barrel (@openora/core/engagement), which re-exports this module's +// contract/ slice; cross-domain table reads go through @openora/core/engagement/schema/social-transfers. +export { SocialTransfersService } from './service/social-transfers.service.js'; +export { createSocialTransfersRouter } from './router/index.js'; diff --git a/packages/core/src/engagement/social-transfers/migrate.ts b/packages/core/src/engagement/social-transfers/migrate.ts new file mode 100644 index 00000000..f8de6c33 --- /dev/null +++ b/packages/core/src/engagement/social-transfers/migrate.ts @@ -0,0 +1,17 @@ +// Applies this module's own migration set against its own tracking table, so it +// never collides with sibling modules that share the database. SQL ships in the +// tarball ('files') and loads via an import.meta.url-relative path. See ADR-0020/0027. +import { fileURLToPath } from 'node:url'; +import { runMigrations } from '@openora/core/server/migrate'; + +/** + * Apply the social-transfers module migrations (idempotent: drizzle skips already-recorded ones). + */ +export function migrate(databaseUrl?: string) { + return runMigrations({ + migrationsFolder: fileURLToPath(new URL('./drizzle/migrations', import.meta.url)), + migrationsTable: '__drizzle_migrations_social_transfers', + migrationsSchema: 'drizzle', + ...(databaseUrl ? { databaseUrl } : {}), + }); +} diff --git a/packages/core/src/engagement/social-transfers/plugin.ts b/packages/core/src/engagement/social-transfers/plugin.ts new file mode 100644 index 00000000..ddc5f7eb --- /dev/null +++ b/packages/core/src/engagement/social-transfers/plugin.ts @@ -0,0 +1,54 @@ +import { EVENT_BUS, DRIZZLE } from '@openora/core/server'; +import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; +import { + CHAT_SYSTEM_WRITER, + WALLET_COMMANDS, + ADMIN_USER_DIRECTORY, + AUDIT_WRITER, + CHAT_REALTIME_TRANSPORT, + CHAT_ROOM_ACCESS, + CACHE, + GIFT_COMMANDS, + RAIN_COMMANDS, + createToken, +} from '@openora/core/contracts'; +import { SocialTransfersService } from './service/social-transfers.service.js'; +import { createSocialTransfersRouter } from './router/index.js'; + +const SOCIAL_TRANSFERS_SERVICE = createToken('_SocialTransfersService'); + +// Owns donate/gift/rain money movement for the chat command surface. Binds +// GIFT_COMMANDS and RAIN_COMMANDS so chat-commands can post/claim gifts and +// send rain without reimplementing the wallet/idempotency/limit logic - see +// this module's AGENTS.md. This module still depends on `chat` for +// CHAT_SYSTEM_WRITER/CHAT_ROOM_ACCESS/CHAT_REALTIME_TRANSPORT.publish (all +// threaded through its own db.transaction, so it must own them directly) - +// but it never queries chat's presence tracking: chat-commands resolves +// online recipients and passes them into RAIN_COMMANDS.sendRain. +export default { + id: 'social-transfers', + dependsOn: ['chat', 'wallet', 'iam', 'audit'], + register(ctx) { + ctx.provide( + SOCIAL_TRANSFERS_SERVICE, + (c) => + new SocialTransfersService( + c.get(DRIZZLE), + c.get(CHAT_SYSTEM_WRITER), + c.get(WALLET_COMMANDS), + c.get(ADMIN_USER_DIRECTORY), + c.get(AUDIT_WRITER), + c.get(CHAT_REALTIME_TRANSPORT), + c.get(EVENT_BUS), + c.get(CHAT_ROOM_ACCESS), + c.get(CACHE), + ), + ); + ctx.provide(GIFT_COMMANDS, (c) => c.get(SOCIAL_TRANSFERS_SERVICE)); + ctx.provide(RAIN_COMMANDS, (c) => c.get(SOCIAL_TRANSFERS_SERVICE)); + + ctx.routers.add('socialTransfers', (c) => + createSocialTransfersRouter(c.get(SOCIAL_TRANSFERS_SERVICE)), + ); + }, +} as const satisfies Plugin; diff --git a/packages/core/src/engagement/social-transfers/router/index.ts b/packages/core/src/engagement/social-transfers/router/index.ts new file mode 100644 index 00000000..19b43789 --- /dev/null +++ b/packages/core/src/engagement/social-transfers/router/index.ts @@ -0,0 +1,40 @@ +import { implement } from '@orpc/server'; +import { mapErrors, getUserId, type OssContext } from '@openora/core/server'; +import { socialTransfersContract } from '../contract/index.js'; +import { + SocialTransfersService, + CommandDisabledError, + InsufficientBalanceError, + ExceedsLimitError, + BelowMinimumError, + DonateSelfError, + ChatPlayerNotFoundError, + ChatCommandIdempotencyKeyReuseError, + ConcurrentCommandReplayError, + ChatRoomNotMemberError, +} from '../service/social-transfers.service.js'; + +export function createSocialTransfersRouter(svc: SocialTransfersService) { + const os = implement(socialTransfersContract).$context(); + + return os.router({ + sendDonate: os.sendDonate.handler(({ input, context }) => { + const actorId = getUserId(context); + return mapErrors( + { + NOT_FOUND: [CommandDisabledError, ChatPlayerNotFoundError], + CONFLICT: [ + InsufficientBalanceError, + ExceedsLimitError, + BelowMinimumError, + DonateSelfError, + ChatCommandIdempotencyKeyReuseError, + ConcurrentCommandReplayError, + ], + FORBIDDEN: [ChatRoomNotMemberError], + }, + () => svc.sendDonate(input, actorId), + ); + }), + }); +} diff --git a/packages/core/src/engagement/social-transfers/schema/index.ts b/packages/core/src/engagement/social-transfers/schema/index.ts new file mode 100644 index 00000000..7a8ac45a --- /dev/null +++ b/packages/core/src/engagement/social-transfers/schema/index.ts @@ -0,0 +1,68 @@ +import { pgTable, uuid, text, decimal, integer, timestamp } from 'drizzle-orm/pg-core'; + +/** + * Claimable gift card: sender is debited on send, first other player to claim + * wins the credit. Successor to chat-commands' `chat_gift` table (left in + * place, untouched - see chat-commands/AGENTS.md and this module's AGENTS.md). + * No FK to chatMessage - cross-module boundary rule. roomId is nullable: a + * gift can be sent into global chat (GLOBAL_CHAT_ROOM_ID sentinel on the wire). + */ +export const playerGift = pgTable('player_gift', { + id: uuid().primaryKey().defaultRandom(), + // System message id from the chat module; plain UUID, no FK across module boundary. + messageId: uuid().notNull(), + senderId: uuid().notNull(), + senderUsername: text().notNull(), + amount: decimal({ precision: 18, scale: 8 }).notNull(), + currency: text().notNull(), + roomId: uuid(), + claimedBy: uuid(), + claimedByUsername: text(), + claimedAt: timestamp({ withTimezone: true }), + createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), +}); +export type PlayerGift = typeof playerGift.$inferSelect; + +/** One row per `/donate` - a direct player-to-player tip. roomId is nullable: donate can happen outside a room context. */ +export const playerDonate = pgTable('player_donate', { + id: uuid().primaryKey().defaultRandom(), + senderId: uuid().notNull(), + senderUsername: text().notNull(), + recipientId: uuid().notNull(), + recipientUsername: text().notNull(), + amount: decimal({ precision: 18, scale: 8 }).notNull(), + currency: text().notNull(), + roomId: uuid(), + createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), +}); +export type PlayerDonate = typeof playerDonate.$inferSelect; + +/** + * Header row per `/rain` event. `amount` is the ACTUALLY-distributed total + * (`floor(typedAmount / recipientCount) * recipientCount`), never the raw + * player-typed amount - see this module's AGENTS.md. roomId is nullable: rain + * can be sent into global chat (GLOBAL_CHAT_ROOM_ID sentinel on the wire). + */ +export const playerRain = pgTable('player_rain', { + id: uuid().primaryKey().defaultRandom(), + senderId: uuid().notNull(), + amount: decimal({ precision: 18, scale: 8 }).notNull(), + perRecipient: decimal({ precision: 18, scale: 8 }).notNull(), + currency: text().notNull(), + roomId: uuid(), + recipientCount: integer().notNull(), + createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), +}); +export type PlayerRain = typeof playerRain.$inferSelect; + +/** One row per rain recipient. In-module FK to `player_rain` - same-module FKs are fine. */ +export const playerRainReceiver = pgTable('player_rain_receiver', { + id: uuid().primaryKey().defaultRandom(), + rainId: uuid() + .notNull() + .references(() => playerRain.id), + recipientId: uuid().notNull(), + amount: decimal({ precision: 18, scale: 8 }).notNull(), + createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), +}); +export type PlayerRainReceiver = typeof playerRainReceiver.$inferSelect; 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 new file mode 100644 index 00000000..be83f7b6 --- /dev/null +++ b/packages/core/src/engagement/social-transfers/service/__tests__/social-transfers.service.test.ts @@ -0,0 +1,853 @@ +import { describe, it, expect, vi } from 'vitest'; +import { mock, makeDrizzle, makeEventBus } from '../../../../testing/mock.js'; +import type { + ChatSystemMessage, + ChatSystemWriter, + WalletCommands, + AdminUserDirectory, + AuditWritePort, + RealtimeTransport, + ChatRoomAccess, + CacheAdapter, +} from '@openora/core/contracts'; +import { + SocialTransfersService, + InsufficientBalanceError, + BelowMinimumError, + DonateSelfError, + ChatPlayerNotFoundError, + fingerprintCommand, +} from '../social-transfers.service.js'; + +const ACTOR_ID = '00000000-0000-0000-0000-000000000001'; +const CLAIMER_ID = '00000000-0000-0000-0000-000000000002'; +const ROOM_ID = '00000000-0000-0000-0000-000000000003'; +const MSG_ID = '00000000-0000-0000-0000-000000000004'; +const GIFT_ID = '00000000-0000-0000-0000-000000000005'; +const OTHER_ROOM_ID = '00000000-0000-0000-0000-000000000009'; + +const ENABLED_ROW = { + key: 'gift', + enabled: true, + label: 'Gift', + description: null, + config: null, + updatedAt: new Date(), +}; + +const DISABLED_ROW = { ...ENABLED_ROW, enabled: false }; + +const SYSTEM_MSG: ChatSystemMessage = { + id: MSG_ID, + roomId: ROOM_ID, + actorId: ACTOR_ID, + content: '', + metadata: { + command: 'gift', + giftId: GIFT_ID, + senderId: ACTOR_ID, + senderUsername: 'bob', + amount: '10.00000000', + currency: 'USD', + }, + createdAt: new Date().toISOString(), +}; + +/** Gift row as returned from the DB (dates as Date objects, amount as string). */ +const GIFT_ROW = { + id: GIFT_ID, + messageId: MSG_ID, + senderId: ACTOR_ID, + senderUsername: 'bob', + amount: '10.00000000', + currency: 'USD', + roomId: ROOM_ID, + claimedBy: null, + claimedByUsername: null, + claimedAt: null, + createdAt: new Date(), +}; + +function makeWriter(): ChatSystemWriter { + return mock({ + postSystemMessage: vi.fn().mockResolvedValue(SYSTEM_MSG), + }); +} + +function makeWallet(ok = true): WalletCommands { + return mock({ + debit: vi + .fn() + .mockResolvedValue( + ok + ? { ok: true, newBalance: '90.00000000', currency: 'USD' } + : { ok: false, available: '5.00000000' }, + ), + credit: vi.fn().mockResolvedValue({ ok: true, newBalance: '110.00000000' }), + }); +} + +const DIRECTORY_CREATED_AT = new Date('2026-01-01T00:00:00.000Z'); + +function makeDirectory(senderUsername = 'bob', claimerUsername = 'alice'): AdminUserDirectory { + const all = [ + { + userId: ACTOR_ID, + username: senderUsername, + email: 'bob@example.com', + kycStatus: null, + language: null, + avatarUrl: null, + createdAt: DIRECTORY_CREATED_AT, + level: 3, + currency: 'USD', + }, + { + userId: CLAIMER_ID, + username: claimerUsername, + email: 'alice@example.com', + kycStatus: null, + language: null, + avatarUrl: null, + createdAt: DIRECTORY_CREATED_AT, + level: 1, + currency: 'USD', + }, + ]; + return mock({ + findPlayerIds: vi.fn().mockResolvedValue([ACTOR_ID]), + lookupPlayers: vi.fn().mockImplementation((ids: string[]) => { + return Promise.resolve(all.filter((p) => ids.includes(p.userId))); + }), + getPlayerByUsername: vi.fn().mockImplementation((username: string) => { + return Promise.resolve( + all.find((p) => p.username.toLowerCase() === username.toLowerCase()) ?? null, + ); + }), + }); +} + +function makeAudit(): AuditWritePort { + return mock({ + record: vi.fn().mockResolvedValue(undefined), + recordInTransaction: vi.fn().mockResolvedValue(undefined), + }); +} + +function makeTransport(onlineIds: string[] = [CLAIMER_ID]): RealtimeTransport { + return mock({ + getOnlineUserIds: vi.fn().mockResolvedValue(onlineIds), + publish: vi.fn().mockResolvedValue(undefined), + }); +} + +function makeRoomAccess(): ChatRoomAccess { + return mock({ verifyRoomAccess: vi.fn().mockResolvedValue(undefined) }); +} + +function makeCache(initial: Record = {}): CacheAdapter { + const values = new Map(Object.entries(initial)); + return { + get: async (key: string): Promise => values.get(key) as T | undefined, + set: vi.fn(async (key: string, value: unknown) => { + values.set(key, value); + }), + setIfAbsent: vi.fn(async (key: string, value: unknown) => { + if (values.has(key)) { + return false; + } + values.set(key, value); + return true; + }), + delete: vi.fn(async (key: string | string[]) => { + for (const item of Array.isArray(key) ? key : [key]) { + values.delete(item); + } + }), + }; +} + +function makeSvc( + overrides: { + drizzleRows?: { + select?: Record[][]; + returning?: Record[][]; + execute?: Record[][]; + }; + writer?: ChatSystemWriter; + wallet?: WalletCommands; + directory?: AdminUserDirectory; + transport?: RealtimeTransport; + audit?: AuditWritePort; + roomAccess?: ChatRoomAccess; + cache?: CacheAdapter; + } = {}, +) { + const drizzle = makeDrizzle({ + select: overrides.drizzleRows?.select ?? [], + returning: overrides.drizzleRows?.returning ?? [], + execute: overrides.drizzleRows?.execute, + }); + return new SocialTransfersService( + drizzle, + overrides.writer ?? makeWriter(), + overrides.wallet ?? makeWallet(), + overrides.directory ?? makeDirectory(), + overrides.audit ?? makeAudit(), + overrides.transport ?? makeTransport(), + mock(makeEventBus()), + overrides.roomAccess ?? makeRoomAccess(), + overrides.cache ?? makeCache(), + ); +} + +const IDEMPOTENCY_KEY = '00000000-0000-0000-0000-0000000000aa'; +const IDEMPOTENCY_CACHE_KEY = `chat-command:idempotency:${ACTOR_ID}:gift:${IDEMPOTENCY_KEY}`; +const RAIN_IDEMPOTENCY_CACHE_KEY = `chat-command:idempotency:${ACTOR_ID}:rain:${IDEMPOTENCY_KEY}`; +const IDEMPOTENCY_ROW_ID = '00000000-0000-0000-0000-0000000000bb'; + +describe('SocialTransfersService.sendGift (GIFT_COMMANDS port)', () => { + it('returns { ok: false, reason: "disabled" } when the command row is missing', async () => { + const svc = makeSvc({ drizzleRows: { select: [[]] } }); + const result = await svc.sendGift( + { amount: '10', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, + ACTOR_ID, + ); + expect(result).toEqual({ ok: false, reason: 'disabled' }); + }); + + it('returns { ok: false, reason: "disabled" } when the command is disabled', async () => { + const svc = makeSvc({ drizzleRows: { select: [[DISABLED_ROW]] } }); + const result = await svc.sendGift( + { amount: '10', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, + ACTOR_ID, + ); + expect(result).toEqual({ ok: false, reason: 'disabled' }); + }); + + it('returns { ok: false, reason: "insufficient_balance" } when the wallet debit fails', async () => { + const svc = makeSvc({ + drizzleRows: { + select: [[ENABLED_ROW]], + returning: [[{ id: IDEMPOTENCY_ROW_ID }]], + }, + wallet: makeWallet(false), + }); + const result = await svc.sendGift( + { amount: '10', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, + ACTOR_ID, + ); + expect(result).toEqual({ ok: false, reason: 'insufficient_balance' }); + }); + + it('posts a system message with gift metadata and returns { ok: true } on success', async () => { + const writer = makeWriter(); + const svc = makeSvc({ + drizzleRows: { + select: [[ENABLED_ROW]], + returning: [[{ ...GIFT_ROW }]], + }, + writer, + }); + const result = await svc.sendGift( + { amount: '10.00000000', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, + ACTOR_ID, + ); + expect(writer.postSystemMessage).toHaveBeenCalledOnce(); + expect(writer.postSystemMessage).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + command: 'gift', + giftId: GIFT_ID, + senderId: ACTOR_ID, + senderUsername: 'bob', + }), + }), + ); + expect(result).toEqual({ ok: true, message: SYSTEM_MSG }); + }); + + it('returns { ok: false, reason: "below_minimum" } when amount is under config minAmount', async () => { + const svc = makeSvc({ + drizzleRows: { select: [[{ ...ENABLED_ROW, config: { minAmount: '5.00000000' } }]] }, + }); + const result = await svc.sendGift( + { amount: '1.00000000', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, + ACTOR_ID, + ); + expect(result).toEqual({ ok: false, reason: 'below_minimum' }); + }); +}); + +describe('SocialTransfersService.sendGift idempotency', () => { + it('inserts the idempotency guard row and debits once when a fresh key is supplied', async () => { + const wallet = makeWallet(); + const svc = makeSvc({ + drizzleRows: { + select: [[ENABLED_ROW], []], + returning: [[{ id: IDEMPOTENCY_ROW_ID }], [{ ...GIFT_ROW }]], + }, + wallet, + }); + const result = await svc.sendGift( + { amount: '10.00000000', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, + ACTOR_ID, + ); + expect(wallet.debit).toHaveBeenCalledOnce(); + expect(result).toEqual({ ok: true, message: SYSTEM_MSG }); + }); + + it('replays the stored result without debiting the wallet again', async () => { + const wallet = makeWallet(); + const giftInput = { + type: 'gift' as const, + amount: '10.00000000', + roomId: ROOM_ID, + idempotencyKey: IDEMPOTENCY_KEY, + }; + const svc = makeSvc({ + drizzleRows: { select: [[ENABLED_ROW]] }, + cache: makeCache({ + [IDEMPOTENCY_CACHE_KEY]: { fingerprint: fingerprintCommand(giftInput), result: SYSTEM_MSG }, + }), + wallet, + }); + const result = await svc.sendGift( + { amount: '10.00000000', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, + ACTOR_ID, + ); + expect(wallet.debit).not.toHaveBeenCalled(); + expect(result).toEqual({ ok: true, message: SYSTEM_MSG }); + }); + + it('returns { ok: false, reason: "idempotency_key_reuse" } when the same key is reused with a different amount', async () => { + const svc = makeSvc({ + drizzleRows: { select: [[ENABLED_ROW]] }, + cache: makeCache({ + [IDEMPOTENCY_CACHE_KEY]: { + fingerprint: fingerprintCommand({ + type: 'gift', + amount: '5.00000000', + roomId: ROOM_ID, + idempotencyKey: IDEMPOTENCY_KEY, + }), + result: SYSTEM_MSG, + }, + }), + }); + const result = await svc.sendGift( + { amount: '10.00000000', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, + ACTOR_ID, + ); + expect(result).toEqual({ ok: false, reason: 'idempotency_key_reuse' }); + }); + + it('returns { ok: false, reason: "idempotency_key_reuse" } when the same key+amount is reused for a different room', async () => { + const svc = makeSvc({ + drizzleRows: { select: [[ENABLED_ROW]] }, + cache: makeCache({ + [IDEMPOTENCY_CACHE_KEY]: { + fingerprint: fingerprintCommand({ + type: 'gift', + amount: '10.00000000', + roomId: OTHER_ROOM_ID, + idempotencyKey: IDEMPOTENCY_KEY, + }), + result: SYSTEM_MSG, + }, + }), + }); + const result = await svc.sendGift( + { amount: '10.00000000', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, + ACTOR_ID, + ); + expect(result).toEqual({ ok: false, reason: 'idempotency_key_reuse' }); + }); +}); + +describe('SocialTransfersService.claimGift', () => { + it('credits the claimer and returns { ok: true } on happy path', async () => { + const wallet = makeWallet(); + const svc = makeSvc({ + drizzleRows: { + select: [[GIFT_ROW]], + returning: [ + [ + { + ...GIFT_ROW, + claimedBy: CLAIMER_ID, + claimedByUsername: 'alice', + claimedAt: new Date(), + }, + ], + ], + }, + wallet, + }); + const result = await svc.claimGift(GIFT_ID, CLAIMER_ID); + expect(wallet.credit).toHaveBeenCalledOnce(); + expect(wallet.credit).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ userId: CLAIMER_ID, type: 'gift' }), + ); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.claimedBy).toBe(CLAIMER_ID); + expect(result.claimedByUsername).toBe('alice'); + expect(result.claimedAt).toEqual(expect.any(String)); + } + }); + + it('returns { ok: false, reason: "self_claim" } when the sender tries to claim their own gift', async () => { + const svc = makeSvc({ + drizzleRows: { select: [[GIFT_ROW]], returning: [] }, // senderId === ACTOR_ID + }); + const result = await svc.claimGift(GIFT_ID, ACTOR_ID); + expect(result).toEqual({ ok: false, reason: 'self_claim' }); + }); + + it('returns { ok: false, reason: "already_claimed" } when the update returns no rows (race lost)', async () => { + const svc = makeSvc({ + drizzleRows: { select: [[GIFT_ROW]], returning: [[]] }, // empty = already claimed + }); + const result = await svc.claimGift(GIFT_ID, CLAIMER_ID); + expect(result).toEqual({ ok: false, reason: 'already_claimed' }); + }); + + it('returns { ok: false, reason: "gift_not_found" } when the gift does not exist', async () => { + const svc = makeSvc({ + drizzleRows: { select: [[]], returning: [] }, // no gift row + }); + const result = await svc.claimGift(GIFT_ID, CLAIMER_ID); + expect(result).toEqual({ ok: false, reason: 'gift_not_found' }); + }); + + it('returns { ok: false, reason: "room_not_member", roomId: } when the claimer is not a room member', async () => { + const notMember = Object.assign(new Error('not a member'), { name: 'ChatRoomNotMemberError' }); + const roomAccess = mock({ + verifyRoomAccess: vi.fn().mockRejectedValue(notMember), + }); + const svc = makeSvc({ + drizzleRows: { select: [[GIFT_ROW]], returning: [] }, + roomAccess, + }); + const result = await svc.claimGift(GIFT_ID, CLAIMER_ID); + expect(result).toEqual({ ok: false, reason: 'room_not_member', roomId: GIFT_ROW.roomId }); + }); +}); + +describe('SocialTransfersService.getGift', () => { + it('returns { ok: true, gift } for a gift in a room the viewer is a member of', async () => { + const svc = makeSvc({ drizzleRows: { select: [[GIFT_ROW]] } }); + const result = await svc.getGift(GIFT_ID, CLAIMER_ID); + expect(result).toEqual({ + ok: true, + gift: { + id: GIFT_ROW.id, + senderId: GIFT_ROW.senderId, + senderUsername: GIFT_ROW.senderUsername, + amount: GIFT_ROW.amount, + currency: GIFT_ROW.currency, + claimedBy: null, + claimedByUsername: null, + claimedAt: null, + createdAt: GIFT_ROW.createdAt.toISOString(), + }, + }); + }); + + it('returns { ok: false, reason: "gift_not_found" } when the gift does not exist', async () => { + const svc = makeSvc({ drizzleRows: { select: [[]] } }); + const result = await svc.getGift(GIFT_ID, CLAIMER_ID); + expect(result).toEqual({ ok: false, reason: 'gift_not_found' }); + }); + + it('returns { ok: false, reason: "room_not_member", roomId: } when the viewer is not a room member', async () => { + const notMember = Object.assign(new Error('not a member'), { name: 'ChatRoomNotMemberError' }); + const roomAccess = mock({ + verifyRoomAccess: vi.fn().mockRejectedValue(notMember), + }); + const svc = makeSvc({ + drizzleRows: { select: [[GIFT_ROW]] }, + roomAccess, + }); + const result = await svc.getGift(GIFT_ID, CLAIMER_ID); + expect(result).toEqual({ ok: false, reason: 'room_not_member', roomId: GIFT_ROW.roomId }); + }); +}); + +describe('SocialTransfersService.sendRain (RAIN_COMMANDS port)', () => { + const RAIN_ROW = { ...ENABLED_ROW, key: 'rain', label: 'Rain' }; + + it('returns { ok: false, reason: "no_online_users" } when onlineUserIds only contains the actor', async () => { + const svc = makeSvc({ drizzleRows: { select: [[RAIN_ROW]] } }); + const result = await svc.sendRain( + { + amount: '10.00000000', + recipientCount: 5, + roomId: ROOM_ID, + idempotencyKey: IDEMPOTENCY_KEY, + onlineUserIds: [ACTOR_ID], + }, + ACTOR_ID, + ); + expect(result).toEqual({ ok: false, reason: 'no_online_users' }); + }); + + it('replays the stored result without debiting the wallet again', async () => { + const wallet = makeWallet(); + const svc = makeSvc({ + drizzleRows: { select: [[RAIN_ROW]] }, + cache: makeCache({ + [RAIN_IDEMPOTENCY_CACHE_KEY]: { + fingerprint: fingerprintCommand({ + type: 'rain', + amount: '10.99000000', + recipientCount: 10, + roomId: ROOM_ID, + idempotencyKey: IDEMPOTENCY_KEY, + }), + result: SYSTEM_MSG, + }, + }), + wallet, + }); + + const result = await svc.sendRain( + { + amount: '10.99000000', + recipientCount: 10, + roomId: ROOM_ID, + idempotencyKey: IDEMPOTENCY_KEY, + onlineUserIds: [], + }, + ACTOR_ID, + ); + expect(result).toEqual({ ok: true, message: SYSTEM_MSG }); + expect(wallet.debit).not.toHaveBeenCalled(); + }); + + 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(); + const svc = makeSvc({ + drizzleRows: { + select: [[RAIN_ROW]], + returning: [[{ id: '00000000-0000-0000-0000-0000000000cc' }]], + execute: [[{ per_recipient: '5.00000000', total_distributed: '10.00000000' }]], + }, + wallet, + writer, + transport, + }); + const result = await svc.sendRain( + { + amount: '10.00000000', + recipientCount: 2, + roomId: ROOM_ID, + idempotencyKey: IDEMPOTENCY_KEY, + onlineUserIds: [ACTOR_ID, CLAIMER_ID, RECIPIENT_2], + }, + ACTOR_ID, + ); + expect(result.ok).toBe(true); + expect(wallet.credit).toHaveBeenCalledTimes(2); + expect(wallet.credit).toHaveBeenCalledWith(expect.anything(), { + userId: CLAIMER_ID, + amount: '5.00000000', + currency: 'USD', + type: 'rain', + }); + expect(writer.postSystemMessage).toHaveBeenCalledOnce(); + expect(writer.postSystemMessage).toHaveBeenCalledWith( + expect.objectContaining({ + roomId: ROOM_ID, + actorId: ACTOR_ID, + metadata: { + command: 'rain', + fromUserId: ACTOR_ID, + amount: '10.00000000', + currency: 'USD', + recipientCount: 2, + perRecipient: '5.00000000', + }, + }), + ); + expect(transport.publish).toHaveBeenCalledWith(`chat:room:${ROOM_ID}`, SYSTEM_MSG); + }); + + it('debits only the amount actually distributed (floor(amount/n)*n), never the raw player-typed amount', async () => { + const wallet = makeWallet(); + const svc = makeSvc({ + drizzleRows: { + select: [[RAIN_ROW]], + returning: [[{ id: '00000000-0000-0000-0000-0000000000cc' }]], + execute: [[{ per_recipient: '1.00000000', total_distributed: '10.00000000' }]], + }, + wallet, + }); + await svc.sendRain( + { + amount: '10.99000000', + recipientCount: 10, + roomId: ROOM_ID, + idempotencyKey: IDEMPOTENCY_KEY, + onlineUserIds: Array.from( + { length: 10 }, + (_, i) => `00000000-0000-0000-0000-0000000000${10 + i}`, + ), + }, + ACTOR_ID, + ); + expect(wallet.debit).toHaveBeenCalledWith(expect.anything(), { + userId: ACTOR_ID, + amount: '10.00000000', + type: 'rain', + }); + }); + + it('returns { ok: false, reason: "below_minimum" } when amount is below config minAmount', async () => { + const svc = makeSvc({ + drizzleRows: { select: [[{ ...RAIN_ROW, config: { minAmount: '5.00000000' } }]] }, + }); + const result = await svc.sendRain( + { + amount: '1.00000000', + recipientCount: 1, + roomId: ROOM_ID, + idempotencyKey: IDEMPOTENCY_KEY, + onlineUserIds: [], + }, + ACTOR_ID, + ); + expect(result).toEqual({ ok: false, reason: 'below_minimum' }); + }); + + it('returns { ok: false, reason: "exceeds_limit" } when amount is above config maxAmount', async () => { + const svc = makeSvc({ + drizzleRows: { select: [[{ ...RAIN_ROW, config: { maxAmount: '10.00000000' } }]] }, + }); + const result = await svc.sendRain( + { + amount: '50.00000000', + recipientCount: 1, + roomId: ROOM_ID, + idempotencyKey: IDEMPOTENCY_KEY, + onlineUserIds: [], + }, + ACTOR_ID, + ); + expect(result).toEqual({ ok: false, reason: 'exceeds_limit' }); + }); + + it('returns { ok: false, reason: "exceeds_limit" } when recipientCount exceeds config maxRecipients', async () => { + const svc = makeSvc({ + drizzleRows: { select: [[{ ...RAIN_ROW, config: { maxRecipients: 10 } }]] }, + }); + const result = await svc.sendRain( + { + amount: '100.00000000', + recipientCount: 11, + roomId: ROOM_ID, + idempotencyKey: IDEMPOTENCY_KEY, + onlineUserIds: [], + }, + ACTOR_ID, + ); + expect(result).toEqual({ ok: false, reason: 'exceeds_limit' }); + }); + + it('returns { ok: false, reason: "too_many_recipients" } when recipientCount exceeds the whole-dollar amount', async () => { + const svc = makeSvc({ drizzleRows: { select: [[RAIN_ROW]] } }); + const result = await svc.sendRain( + { + amount: '3.00000000', + recipientCount: 4, + roomId: ROOM_ID, + idempotencyKey: IDEMPOTENCY_KEY, + onlineUserIds: [], + }, + ACTOR_ID, + ); + expect(result).toEqual({ ok: false, reason: 'too_many_recipients' }); + }); +}); + +const DONATE_ROW = { + key: 'donate', + enabled: true, + label: 'Donate', + description: 'Send a direct tip to a specific player', + config: null, + updatedAt: new Date(), +}; + +const DONATE_SYSTEM_MSG: ChatSystemMessage = { + id: MSG_ID, + roomId: ROOM_ID, + actorId: ACTOR_ID, + content: '', + metadata: { + command: 'donate', + recipientId: CLAIMER_ID, + recipientUsername: 'alice', + amount: '10.00000000', + currency: 'USD', + }, + createdAt: new Date().toISOString(), +}; + +/** + * Directory mock where findPlayerIds resolves the RECIPIENT (CLAIMER_ID = 'alice'). + * Used for donate tests that target 'alice' - the default makeDirectory() always + * returns ACTOR_ID which only has username 'bob', so alice is never found. + */ +function makeRecipientDirectory(): AdminUserDirectory { + const all = [ + { + userId: ACTOR_ID, + username: 'bob', + email: 'bob@example.com', + kycStatus: null, + language: null, + avatarUrl: null, + createdAt: DIRECTORY_CREATED_AT, + level: 3, + currency: 'USD', + }, + { + userId: CLAIMER_ID, + username: 'alice', + email: 'alice@example.com', + kycStatus: null, + language: null, + avatarUrl: null, + createdAt: DIRECTORY_CREATED_AT, + level: 1, + currency: 'USD', + }, + ]; + return mock({ + findPlayerIds: vi.fn().mockResolvedValue([CLAIMER_ID]), + lookupPlayers: vi.fn().mockImplementation((ids: string[]) => { + return Promise.resolve(all.filter((p) => ids.includes(p.userId))); + }), + getPlayerByUsername: vi.fn().mockImplementation((username: string) => { + return Promise.resolve( + all.find((p) => p.username.toLowerCase() === username.toLowerCase()) ?? null, + ); + }), + }); +} + +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), + }); + const svc = makeSvc({ + drizzleRows: { + select: [[DONATE_ROW]], + returning: [[{ id: '00000000-0000-0000-0000-0000000000dd' }]], + }, + wallet, + writer, + directory: makeRecipientDirectory(), + }); + const result = await svc.sendDonate( + { + targetUsername: 'alice', + amount: '10.00000000', + roomId: ROOM_ID, + idempotencyKey: IDEMPOTENCY_KEY, + }, + ACTOR_ID, + ); + expect(wallet.debit).toHaveBeenCalledWith(expect.anything(), { + userId: ACTOR_ID, + amount: '10.00000000', + type: 'tip', + }); + expect(wallet.credit).toHaveBeenCalledWith(expect.anything(), { + userId: CLAIMER_ID, + amount: '10.00000000', + currency: 'USD', + type: 'tip', + }); + expect(result.id).toBe(MSG_ID); + }); + + it('throws DonateSelfError when the sender targets themselves', async () => { + const svc = makeSvc({ drizzleRows: { select: [[DONATE_ROW]] } }); + await expect( + svc.sendDonate( + { + targetUsername: 'bob', + amount: '10.00000000', + roomId: ROOM_ID, + idempotencyKey: IDEMPOTENCY_KEY, + }, + ACTOR_ID, + ), + ).rejects.toThrow(DonateSelfError); + }); + + it('throws ChatPlayerNotFoundError when the target username does not exist', async () => { + const directory = mock({ + findPlayerIds: vi.fn().mockResolvedValue([]), + lookupPlayers: vi.fn().mockResolvedValue([]), + getPlayerByUsername: vi.fn().mockResolvedValue(null), + }); + const svc = makeSvc({ drizzleRows: { select: [[DONATE_ROW]] }, directory }); + await expect( + svc.sendDonate( + { + targetUsername: 'ghost', + amount: '10.00000000', + roomId: ROOM_ID, + idempotencyKey: IDEMPOTENCY_KEY, + }, + ACTOR_ID, + ), + ).rejects.toThrow(ChatPlayerNotFoundError); + }); + + it('throws InsufficientBalanceError when the sender wallet debit fails', async () => { + const svc = makeSvc({ + drizzleRows: { select: [[DONATE_ROW]], returning: [[{ id: IDEMPOTENCY_ROW_ID }]] }, + wallet: makeWallet(false), + directory: makeRecipientDirectory(), + }); + await expect( + svc.sendDonate( + { + targetUsername: 'alice', + amount: '10.00000000', + roomId: ROOM_ID, + idempotencyKey: IDEMPOTENCY_KEY, + }, + ACTOR_ID, + ), + ).rejects.toThrow(InsufficientBalanceError); + }); + + it('throws BelowMinimumError when amount is below config minAmount', async () => { + const svc = makeSvc({ + drizzleRows: { select: [[{ ...DONATE_ROW, config: { minAmount: '5.00000000' } }]] }, + }); + await expect( + svc.sendDonate( + { + targetUsername: 'alice', + amount: '1.00000000', + roomId: ROOM_ID, + idempotencyKey: IDEMPOTENCY_KEY, + }, + ACTOR_ID, + ), + ).rejects.toThrow(BelowMinimumError); + }); +}); 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 new file mode 100644 index 00000000..4e996f14 --- /dev/null +++ b/packages/core/src/engagement/social-transfers/service/social-transfers.service.ts @@ -0,0 +1,963 @@ +import { createHash } from 'node:crypto'; +import { and, eq, isNull, sql } from 'drizzle-orm'; +import { + DrizzleService, + makeNotFoundError, + makeConflictError, + mapConcurrent, + moneyToNumber, + findOneOrThrow, + createDomainError, + type EventBus, +} from '@openora/core/server'; +import type { + Uuid, + ChatSystemMessage, + ChatSystemWriter, + WalletCommands, + AdminUserDirectory, + AuditWritePort, + RealtimeTransport, + ChatRoomAccess, + CacheAdapter, + SendGiftArgs, + SendGiftResult, + ClaimGiftResult, + GetGiftResult, + GiftSnapshot, + GiftCommands, + SendRainArgs, + SendRainResult, + RainCommands, +} from '@openora/core/contracts'; +import { chatChannel } from '@openora/core/contracts'; +import { chatCommandConfig } from '@openora/core/engagement/schema/chat-commands'; +import type { SendDonateInput } from '../contract/index.js'; +import { playerGift, playerDonate, playerRain, playerRainReceiver } from '../schema/index.js'; + +export const CommandDisabledError = makeNotFoundError('ChatCommand'); +export const NoOnlineUsersError = makeConflictError( + 'NoOnlineUsers', + 'No other users are online in this room', +); +export const InsufficientBalanceError = makeConflictError( + 'InsufficientBalance', + 'Not enough balance', +); +export const ExceedsLimitError = makeConflictError( + 'ExceedsLimit', + 'Amount exceeds the command limit', +); +export const BelowMinimumError = makeConflictError( + 'BelowMinimum', + 'Amount is below the minimum for this command', +); +export const RainCreditError = makeConflictError( + 'RainCreditError', + 'A recipient wallet is unavailable; rain aborted', +); +export const ChatPlayerNotFoundError = makeNotFoundError('ChatPlayer'); +export const GiftNotFoundError = makeNotFoundError('ChatGift'); +export const GiftAlreadyClaimedError = makeConflictError( + 'GiftAlreadyClaimed', + 'This gift has already been claimed', +); +export const GiftSelfClaimError = makeConflictError( + 'GiftSelfClaim', + 'You cannot claim your own gift', +); +export const GiftCreditError = makeConflictError( + 'GiftCreditError', + 'Recipient wallet is unavailable; gift claim aborted', +); +export const DonateSelfError = makeConflictError('DonateSelf', 'You cannot donate to yourself'); +export const TooManyRecipientsError = makeConflictError( + 'TooManyRecipients', + 'Amount too small: you need at least $1 per recipient', +); +export const ChatCommandIdempotencyKeyReuseError = makeConflictError( + 'ChatCommandIdempotencyKeyReuse', + 'This idempotency key was already used with different request parameters', +); +export const ConcurrentCommandReplayError = makeConflictError( + 'ConcurrentCommandReplay', + 'This request is already being processed - please retry', +); +export const ChatRoomNotMemberError = createDomainError( + 'ChatRoomNotMemberError', + (roomId: Uuid) => `You are not a member of room: ${roomId}`, +); + +// Internal-only: createDomainError instances don't expose their constructor args as +// properties, so this carries the gift's roomId across the doClaimGift -> toClaimGiftResult +// boundary - without it, the ClaimGiftResult's room_not_member reason has no roomId, and +// chat-commands (which only has giftId in scope) would build a ChatRoomNotMemberError with +// the wrong id. Never crosses the GIFT_COMMANDS port boundary itself. +class GiftRoomAccessError extends Error { + constructor(readonly roomId: Uuid | null) { + super('room_not_member'); + this.name = 'GiftRoomAccessError'; + } +} + +type GiftArgs = { amount: string; roomId: Uuid | null; idempotencyKey: Uuid }; +type RainFingerprintArgs = Pick< + SendRainArgs, + 'amount' | 'recipientCount' | 'roomId' | 'idempotencyKey' +>; +type DonateArgs = { + targetUsername: string; + amount: string; + roomId: Uuid | null; + idempotencyKey: Uuid; +}; +type MoneyMovingInput = + | ({ type: 'gift' } & GiftArgs) + | ({ type: 'rain' } & RainFingerprintArgs) + | ({ type: 'donate' } & DonateArgs); + +const COMMAND_IDEMPOTENCY_TTL_MS = 5 * 60 * 1000; +type CommandIdempotencyRecord = { + fingerprint: string; + result: ChatSystemMessage | null; +}; + +// The replay guard must match on the COMPLETE request, not just the amount - a reused +// key with a different room, recipient count, or donate target is a distinct request, +// not a replay of the original. `idempotencyKey` itself is excluded so the fingerprint +// is stable for the row it guards. The cache-key namespace stays `chat-command:*` even +// though this logic now lives here - see AGENTS.md. +export function fingerprintCommand(input: MoneyMovingInput): string { + const canonical: Record = + input.type === 'gift' + ? { type: input.type, amount: input.amount, roomId: input.roomId } + : input.type === 'rain' + ? { + type: input.type, + amount: input.amount, + recipientCount: input.recipientCount, + roomId: input.roomId, + } + : { + type: input.type, + amount: input.amount, + targetUsername: input.targetUsername, + roomId: input.roomId, + }; + return createHash('sha256').update(JSON.stringify(canonical)).digest('hex'); +} + +function shuffleArray(arr: readonly T[]): T[] { + const result = [...arr]; + for (let i = result.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const a = result.at(i); + const b = result.at(j); + if (a !== undefined && b !== undefined) { + result[i] = b; + result[j] = a; + } + } + return result; +} + +// Translates a thrown domain error into the GIFT_COMMANDS port's discriminated +// result so chat-commands never needs to import this module's error classes +// (module boundary) - only genuinely unexpected errors still throw across the +// port. `.name`-based fallback covers an error surfacing from CHAT_ROOM_ACCESS +// (a different module's class), mirroring the sender-side room-access guard. +function toSendGiftResult(error: unknown): SendGiftResult { + if (error instanceof CommandDisabledError) { + return { ok: false, reason: 'disabled' }; + } + if (error instanceof InsufficientBalanceError) { + return { ok: false, reason: 'insufficient_balance' }; + } + if (error instanceof ExceedsLimitError) { + return { ok: false, reason: 'exceeds_limit' }; + } + if (error instanceof BelowMinimumError) { + return { ok: false, reason: 'below_minimum' }; + } + if (error instanceof ChatCommandIdempotencyKeyReuseError) { + return { ok: false, reason: 'idempotency_key_reuse' }; + } + if (error instanceof ConcurrentCommandReplayError) { + return { ok: false, reason: 'concurrent_replay' }; + } + if ( + error instanceof ChatRoomNotMemberError || + (error instanceof Error && error.name === 'ChatRoomNotMemberError') + ) { + return { ok: false, reason: 'room_not_member' }; + } + throw error; +} + +function toClaimGiftResult(error: unknown): ClaimGiftResult { + if (error instanceof GiftNotFoundError) { + return { ok: false, reason: 'gift_not_found' }; + } + if (error instanceof GiftAlreadyClaimedError) { + return { ok: false, reason: 'already_claimed' }; + } + if (error instanceof GiftSelfClaimError) { + return { ok: false, reason: 'self_claim' }; + } + if (error instanceof GiftRoomAccessError) { + return { ok: false, reason: 'room_not_member', roomId: error.roomId }; + } + if (error instanceof GiftCreditError) { + return { ok: false, reason: 'gift_credit_failed' }; + } + throw error; +} + +function toGetGiftResult(error: unknown): GetGiftResult { + if (error instanceof GiftNotFoundError) { + return { ok: false, reason: 'gift_not_found' }; + } + if (error instanceof GiftRoomAccessError) { + return { ok: false, reason: 'room_not_member', roomId: error.roomId }; + } + throw error; +} + +function toSendRainResult(error: unknown): SendRainResult { + if (error instanceof CommandDisabledError) { + return { ok: false, reason: 'disabled' }; + } + if (error instanceof InsufficientBalanceError) { + return { ok: false, reason: 'insufficient_balance' }; + } + if (error instanceof ExceedsLimitError) { + return { ok: false, reason: 'exceeds_limit' }; + } + if (error instanceof BelowMinimumError) { + return { ok: false, reason: 'below_minimum' }; + } + if (error instanceof NoOnlineUsersError) { + return { ok: false, reason: 'no_online_users' }; + } + if (error instanceof TooManyRecipientsError) { + return { ok: false, reason: 'too_many_recipients' }; + } + if (error instanceof RainCreditError) { + return { ok: false, reason: 'rain_credit_failed' }; + } + if (error instanceof ChatCommandIdempotencyKeyReuseError) { + return { ok: false, reason: 'idempotency_key_reuse' }; + } + if (error instanceof ConcurrentCommandReplayError) { + return { ok: false, reason: 'concurrent_replay' }; + } + if ( + error instanceof ChatRoomNotMemberError || + (error instanceof Error && error.name === 'ChatRoomNotMemberError') + ) { + return { ok: false, reason: 'room_not_member' }; + } + throw error; +} + +export class SocialTransfersService implements GiftCommands, RainCommands { + constructor( + private readonly drizzle: DrizzleService, + private readonly systemWriter: ChatSystemWriter, + private readonly wallet: WalletCommands, + private readonly directory: AdminUserDirectory, + private readonly audit: AuditWritePort, + private readonly transport: RealtimeTransport, + private readonly events: EventBus, + private readonly roomAccess: ChatRoomAccess, + private readonly cache: CacheAdapter, + private readonly idempotencyTtlMs = COMMAND_IDEMPOTENCY_TTL_MS, + ) {} + + // GIFT_COMMANDS port impl, consumed by chat-commands via DI - never throws, + // every failure the caller must distinguish comes back as a typed `reason`. + async sendGift(input: SendGiftArgs, actorId: Uuid): Promise { + try { + const message = await this.doSendGift(input, actorId); + return { ok: true, message }; + } catch (error) { + return toSendGiftResult(error); + } + } + + async claimGift(giftId: Uuid, claimerId: Uuid): Promise { + try { + return await this.doClaimGift(giftId, claimerId); + } catch (error) { + return toClaimGiftResult(error); + } + } + + async getGift(giftId: Uuid, viewerId: Uuid): Promise { + try { + const gift = await this.doGetGift(giftId, viewerId); + return { ok: true, gift }; + } catch (error) { + return toGetGiftResult(error); + } + } + + // RAIN_COMMANDS port impl, consumed by chat-commands via DI - never throws. + // `input.onlineUserIds` is resolved by the caller (chat-commands owns + // presence via its own dependency on `chat`); this module never queries + // online status itself. + async sendRain(input: SendRainArgs, actorId: Uuid): Promise { + try { + const message = await this.doSendRain(input, actorId); + return { ok: true, message }; + } catch (error) { + return toSendRainResult(error); + } + } + + async sendDonate(input: SendDonateInput, actorId: Uuid): Promise { + return this.doSendDonate(input, actorId); + } + + private async loadCommandConfig(key: 'gift' | 'rain' | 'donate') { + const [row] = await this.drizzle.db + .select() + .from(chatCommandConfig) + .where(eq(chatCommandConfig.key, key)) + .limit(1); + if (!row || !row.enabled) { + throw new CommandDisabledError(key); + } + return row.config ?? null; + } + + private async verifyRoomAccessIfNeeded(roomId: Uuid | null, actorId: Uuid): Promise { + if (!roomId) { + return; + } + try { + await this.roomAccess.verifyRoomAccess(roomId, actorId); + } catch (error) { + if (error instanceof Error && error.name === 'ChatRoomNotMemberError') { + throw new ChatRoomNotMemberError(roomId); + } + throw error; + } + } + + private async findCommandReplay( + commandType: 'gift' | 'rain' | 'donate', + actorId: Uuid, + idempotencyKey: Uuid, + fingerprint: string, + ): Promise { + const record = await this.cache.get( + this.idempotencyCacheKey(commandType, actorId, idempotencyKey), + ); + if (!record) { + return null; + } + if (record.fingerprint !== fingerprint) { + throw new ChatCommandIdempotencyKeyReuseError(); + } + if (!record.result) { + throw new ConcurrentCommandReplayError(); + } + return record.result; + } + + private idempotencyCacheKey( + commandType: 'gift' | 'rain' | 'donate', + actorId: Uuid, + idempotencyKey: Uuid, + ): string { + return `chat-command:idempotency:${actorId}:${commandType}:${idempotencyKey}`; + } + + private async reserveCommandIdempotency( + commandType: 'gift' | 'rain' | 'donate', + actorId: Uuid, + idempotencyKey: Uuid, + fingerprint: string, + ): Promise { + const reserved = await this.cache.setIfAbsent( + this.idempotencyCacheKey(commandType, actorId, idempotencyKey), + { fingerprint, result: null } satisfies CommandIdempotencyRecord, + { ttlMs: this.idempotencyTtlMs }, + ); + if (!reserved) { + throw new ConcurrentCommandReplayError(); + } + } + + private async completeCommandIdempotency( + commandType: 'gift' | 'rain' | 'donate', + actorId: Uuid, + idempotencyKey: Uuid, + fingerprint: string, + result: ChatSystemMessage, + ): Promise { + await this.cache.set( + this.idempotencyCacheKey(commandType, actorId, idempotencyKey), + { fingerprint, result } satisfies CommandIdempotencyRecord, + { ttlMs: this.idempotencyTtlMs }, + ); + } + + private async runWithCommandReservation( + commandType: 'gift' | 'rain' | 'donate', + actorId: Uuid, + idempotencyKey: Uuid, + work: () => Promise, + ): Promise { + try { + return await work(); + } catch (error) { + await this.cache.delete(this.idempotencyCacheKey(commandType, actorId, idempotencyKey)); + throw error; + } + } + + // Exact, case-insensitive username resolution for callers holding a complete, already-known + // username (never a partial search term) - `/donate`. Distinct from findPlayerIds' capped + // fuzzy substring search, which can silently drop the real match once more than 20 unrelated + // accounts substring-collide with a short/common username. + private async resolveExactPlayer(username: string) { + const summary = await this.directory.getPlayerByUsername(username); + if (!summary) { + throw new ChatPlayerNotFoundError(username); + } + return summary; + } + + private async doSendGift(input: GiftArgs, actorId: Uuid): Promise { + await this.verifyRoomAccessIfNeeded(input.roomId, actorId); + const config = await this.loadCommandConfig('gift'); + if ( + config?.maxAmount !== undefined && + moneyToNumber(input.amount) > moneyToNumber(config.maxAmount) + ) { + throw new ExceedsLimitError(); + } + if ( + config?.minAmount !== undefined && + moneyToNumber(input.amount) < moneyToNumber(config.minAmount) + ) { + throw new BelowMinimumError(); + } + + const senderSummaries = await this.directory.lookupPlayers([actorId]); + const senderSummary = senderSummaries.find((s) => s.userId === actorId); + if (!senderSummary) { + throw new ChatPlayerNotFoundError(actorId); + } + const senderUsername = senderSummary.username; + + // Sender resolved BEFORE reserving (mirrors doSendDonate): runWithCommandReservation + // only releases the reservation on a failure inside its own callback, so anything + // between reserveCommandIdempotency and that callback would otherwise leak a stuck + // reservation for the full TTL on a transient directory error. + const fingerprint = fingerprintCommand({ type: 'gift', ...input }); + const replay = await this.findCommandReplay('gift', actorId, input.idempotencyKey, fingerprint); + if (replay) { + return replay; + } + await this.reserveCommandIdempotency('gift', actorId, input.idempotencyKey, fingerprint); + + const { msg, giftId, currency } = await this.runWithCommandReservation( + 'gift', + actorId, + input.idempotencyKey, + () => + this.drizzle.db.transaction(async (tx) => { + const debit = await this.wallet.debit(tx, { + userId: actorId, + amount: input.amount, + type: 'gift', + }); + if (!debit.ok) { + throw new InsufficientBalanceError(); + } + + const [giftRow] = await tx + .insert(playerGift) + .values({ + senderId: actorId, + senderUsername, + amount: input.amount, + currency: debit.currency, + roomId: input.roomId, + // messageId is back-filled below, in the same transaction, once the + // system message exists (same insert-then-update pattern as before). + messageId: '00000000-0000-0000-0000-000000000000', + }) + .returning(); + + if (!giftRow) { + throw new InsufficientBalanceError(); + } + + const systemMsg = await this.systemWriter.postSystemMessage({ + roomId: input.roomId, + actorId, + tx, + metadata: { + command: 'gift', + giftId: giftRow.id, + senderId: actorId, + senderUsername, + amount: input.amount, + currency: debit.currency, + }, + }); + + await tx + .update(playerGift) + .set({ messageId: systemMsg.id }) + .where(eq(playerGift.id, giftRow.id)); + + await this.audit.recordInTransaction(tx, { + actorId, + actorType: 'player', + action: 'chat.gift', + resourceType: 'player_gift', + resourceId: giftRow.id, + before: null, + after: { amount: input.amount, roomId: input.roomId }, + }); + + return { msg: systemMsg, 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 + // it did not auto-publish - publish only now that this transaction has committed. + void this.transport.publish(chatChannel(input.roomId), msg); + + void this.events.emit('chat.gift.sent', { + giftId, + senderId: actorId, + senderUsername, + amount: input.amount, + currency, + roomId: input.roomId, + messageId: msg.id, + }); + + return msg; + } + + private async doClaimGift(giftId: Uuid, claimerId: Uuid): Promise { + const claimerSummaries = await this.directory.lookupPlayers([claimerId]); + const claimerSummary = claimerSummaries.find((s) => s.userId === claimerId); + if (!claimerSummary) { + throw new GiftNotFoundError(claimerId); + } + const claimerUsername = claimerSummary.username; + + const { claimed, currency, roomId, senderId } = await this.drizzle.db.transaction( + async (tx) => { + // FOR UPDATE serializes concurrent claims against the same gift row under READ COMMITTED. + const giftRow = findOneOrThrow( + await tx.select().from(playerGift).where(eq(playerGift.id, giftId)).for('update'), + new GiftNotFoundError(giftId), + ); + + try { + await this.verifyRoomAccessIfNeeded(giftRow.roomId, claimerId); + } catch (error) { + if (error instanceof Error && error.name === 'ChatRoomNotMemberError') { + throw new GiftRoomAccessError(giftRow.roomId); + } + throw error; + } + + if (giftRow.senderId === claimerId) { + throw new GiftSelfClaimError(); + } + if (giftRow.claimedBy) { + throw new GiftAlreadyClaimedError(); + } + + const claimedAt = new Date(); + // Guarded conditional update: WHERE claimed_by IS NULL stays as defense-in-depth + // alongside the row lock above (same pattern as wallet's guarded debit). + const results = await tx + .update(playerGift) + .set({ claimedBy: claimerId, claimedByUsername: claimerUsername, claimedAt }) + .where(and(eq(playerGift.id, giftId), isNull(playerGift.claimedBy))) + .returning(); + + const updated = findOneOrThrow(results, new GiftAlreadyClaimedError()); + + const credit = await this.wallet.credit(tx, { + userId: claimerId, + amount: updated.amount, + currency: updated.currency, + type: 'gift', + }); + if (!credit.ok) { + throw new GiftCreditError(); + } + + await this.audit.recordInTransaction(tx, { + actorId: claimerId, + actorType: 'player', + action: 'chat.gift.claimed', + resourceType: 'player_gift', + resourceId: giftId, + before: null, + after: { claimedBy: claimerId, amount: updated.amount }, + }); + + return { + claimed: updated, + currency: updated.currency, + roomId: updated.roomId, + senderId: giftRow.senderId, + }; + }, + ); + + const claimedAtIso = claimed.claimedAt?.toISOString() ?? new Date().toISOString(); + + void this.transport.publish(chatChannel(roomId), { + event: 'gift.claimed', + giftId, + claimedBy: claimerId, + claimedByUsername: claimerUsername, + claimedAt: claimedAtIso, + }); + + void this.events.emit('chat.gift.claimed', { + giftId, + claimerId, + claimerUsername, + senderId, + amount: claimed.amount, + currency, + roomId, + }); + + return { + ok: true, + claimedBy: claimerId, + claimedByUsername: claimerUsername, + claimedAt: claimedAtIso, + }; + } + + private async doGetGift(giftId: Uuid, viewerId: Uuid): Promise { + const rows = await this.drizzle.db.select().from(playerGift).where(eq(playerGift.id, giftId)); + const giftRow = findOneOrThrow(rows, new GiftNotFoundError(giftId)); + + // Same room-membership boundary as doClaimGift: a viewer must be a member + // of the gift's room to poll its status. No FOR UPDATE - read-only, no + // mutation, no need to serialize against concurrent claims. + try { + await this.verifyRoomAccessIfNeeded(giftRow.roomId, viewerId); + } catch (error) { + if (error instanceof Error && error.name === 'ChatRoomNotMemberError') { + throw new GiftRoomAccessError(giftRow.roomId); + } + throw error; + } + + return { + id: giftRow.id, + senderId: giftRow.senderId, + senderUsername: giftRow.senderUsername, + amount: giftRow.amount, + currency: giftRow.currency, + claimedBy: giftRow.claimedBy, + claimedByUsername: giftRow.claimedByUsername, + claimedAt: giftRow.claimedAt ? giftRow.claimedAt.toISOString() : null, + createdAt: giftRow.createdAt.toISOString(), + }; + } + + private async doSendRain(input: SendRainArgs, actorId: Uuid): Promise { + await this.verifyRoomAccessIfNeeded(input.roomId, actorId); + const config = await this.loadCommandConfig('rain'); + if ( + config?.maxAmount !== undefined && + moneyToNumber(input.amount) > moneyToNumber(config.maxAmount) + ) { + throw new ExceedsLimitError(); + } + if ( + config?.minAmount !== undefined && + moneyToNumber(input.amount) < moneyToNumber(config.minAmount) + ) { + throw new BelowMinimumError(); + } + + const configMax = config?.maxRecipients ?? 50; + if (input.recipientCount > configMax) { + throw new ExceedsLimitError(); + } + const amountUnits = Math.floor(moneyToNumber(input.amount)); + if (input.recipientCount > amountUnits) { + throw new TooManyRecipientsError(); + } + const fingerprint = fingerprintCommand({ type: 'rain', ...input }); + const replay = await this.findCommandReplay('rain', actorId, input.idempotencyKey, fingerprint); + if (replay) { + return replay; + } + await this.reserveCommandIdempotency('rain', actorId, input.idempotencyKey, fingerprint); + + // Recipients resolved INSIDE runWithCommandReservation's callback (unlike doSendGift's + // pre-reservation directory lookup): runWithCommandReservation only releases the reservation + // on a failure inside its own callback, so throwing NoOnlineUsersError here - after + // reserveCommandIdempotency but before the callback - would otherwise leak a stuck reservation + // for the full TTL. It must also stay after findCommandReplay/reserveCommandIdempotency so a + // replay with a now-empty onlineUserIds list (the caller retried after everyone left) still + // returns the original stored result instead of a spurious NoOnlineUsersError. + const { msg, currency, totalDistributed, recipientIds } = await this.runWithCommandReservation( + 'rain', + actorId, + input.idempotencyKey, + () => { + const recipients = shuffleArray(input.onlineUserIds.filter((id) => id !== actorId)).slice( + 0, + input.recipientCount, + ); + if (recipients.length === 0) { + throw new NoOnlineUsersError(); + } + return this.drizzle.db.transaction(async (tx) => { + const splitResult = await tx.execute( + sql`SELECT + (floor(floor(${input.amount}::numeric) / ${recipients.length}))::text AS per_recipient, + (floor(floor(${input.amount}::numeric) / ${recipients.length}) * ${recipients.length})::text AS total_distributed`, + ); + const { per_recipient: perRecipient, total_distributed: totalDistributed } = splitResult + .rows[0] as { + per_recipient: string; + total_distributed: string; + }; + const debit = await this.wallet.debit(tx, { + userId: actorId, + amount: totalDistributed, + type: 'rain', + }); + if (!debit.ok) { + throw new InsufficientBalanceError(); + } + const credits = await mapConcurrent(recipients, 10, (userId) => + this.wallet.credit(tx, { + userId, + amount: perRecipient, + currency: debit.currency, + type: 'rain', + }), + ); + if (credits.some((c) => !c.ok)) { + throw new RainCreditError(); + } + + const [rainRow] = await tx + .insert(playerRain) + .values({ + senderId: actorId, + amount: totalDistributed, + perRecipient, + currency: debit.currency, + roomId: input.roomId, + recipientCount: recipients.length, + }) + .returning(); + if (!rainRow) { + throw new RainCreditError(); + } + await tx.insert(playerRainReceiver).values( + recipients.map((recipientId) => ({ + rainId: rainRow.id, + recipientId, + amount: perRecipient, + })), + ); + + const systemMsg = await this.systemWriter.postSystemMessage({ + roomId: input.roomId, + actorId, + tx, + metadata: { + command: 'rain', + fromUserId: actorId, + amount: totalDistributed, + currency: debit.currency, + recipientCount: recipients.length, + perRecipient, + }, + }); + + await this.audit.recordInTransaction(tx, { + actorId, + actorType: 'player', + action: 'chat.rain', + resourceType: 'player_rain', + resourceId: rainRow.id, + before: null, + after: { amount: totalDistributed, recipientCount: recipients.length }, + }); + + return { + msg: systemMsg, + currency: debit.currency, + totalDistributed, + perRecipient, + recipientIds: recipients, + }; + }); + }, + ); + await this.completeCommandIdempotency('rain', actorId, input.idempotencyKey, fingerprint, msg); + + // The caller now owns the commit boundary: postSystemMessage 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); + + void this.events.emit('chat.rain.distributed', { + fromUserId: actorId, + recipients: recipientIds, + recipientCount: recipientIds.length, + totalAmount: totalDistributed, + currency, + roomId: input.roomId, + }); + return msg; + } + + private async doSendDonate(input: DonateArgs, actorId: Uuid): Promise { + await this.verifyRoomAccessIfNeeded(input.roomId, actorId); + const config = await this.loadCommandConfig('donate'); + if ( + config?.maxAmount !== undefined && + moneyToNumber(input.amount) > moneyToNumber(config.maxAmount) + ) { + throw new ExceedsLimitError(); + } + if ( + config?.minAmount !== undefined && + moneyToNumber(input.amount) < moneyToNumber(config.minAmount) + ) { + throw new BelowMinimumError(); + } + + const target = await this.resolveExactPlayer(input.targetUsername); + + if (target.userId === actorId) { + throw new DonateSelfError(); + } + + 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: 'donate', ...input }); + const replay = await this.findCommandReplay( + 'donate', + actorId, + input.idempotencyKey, + fingerprint, + ); + if (replay) { + return replay; + } + await this.reserveCommandIdempotency('donate', actorId, input.idempotencyKey, fingerprint); + + const { msg, currency } = await this.runWithCommandReservation( + 'donate', + actorId, + input.idempotencyKey, + () => + this.drizzle.db.transaction(async (tx) => { + const debit = await this.wallet.debit(tx, { + userId: actorId, + amount: input.amount, + type: 'tip', + }); + if (!debit.ok) { + throw new InsufficientBalanceError(); + } + + const credit = await this.wallet.credit(tx, { + userId: target.userId, + amount: input.amount, + currency: debit.currency, + type: 'tip', + }); + if (!credit.ok) { + throw new ChatPlayerNotFoundError(target.userId); + } + + const systemMsg = await this.systemWriter.postSystemMessage({ + roomId: input.roomId, + actorId, + tx, + metadata: { + command: 'donate', + recipientId: target.userId, + recipientUsername: target.username, + amount: input.amount, + currency: debit.currency, + }, + }); + + const [donateRow] = await tx + .insert(playerDonate) + .values({ + senderId: actorId, + senderUsername: sender.username, + recipientId: target.userId, + recipientUsername: target.username, + amount: input.amount, + currency: debit.currency, + roomId: input.roomId, + }) + .returning(); + if (!donateRow) { + throw new ChatPlayerNotFoundError(target.userId); + } + + await this.audit.recordInTransaction(tx, { + actorId, + actorType: 'player', + action: 'chat.donate', + resourceType: 'player_donate', + resourceId: donateRow.id, + before: null, + after: { recipientId: target.userId, amount: input.amount, currency: debit.currency }, + }); + + return { msg: systemMsg, currency: debit.currency }; + }), + ); + await this.completeCommandIdempotency( + 'donate', + actorId, + input.idempotencyKey, + fingerprint, + msg, + ); + + void this.transport.publish(chatChannel(input.roomId), msg); + + void this.events.emit('chat.donate.sent', { + senderId: actorId, + senderUsername: sender.username, + recipientId: target.userId, + recipientUsername: target.username, + amount: input.amount, + currency, + roomId: input.roomId, + }); + + return msg; + } +} diff --git a/packages/core/src/pam/identity/__tests__/identity.service.int.test.ts b/packages/core/src/pam/identity/__tests__/identity.service.int.test.ts index be0af76b..898c3777 100644 --- a/packages/core/src/pam/identity/__tests__/identity.service.int.test.ts +++ b/packages/core/src/pam/identity/__tests__/identity.service.int.test.ts @@ -4,6 +4,8 @@ import { eq, sql } from 'drizzle-orm'; import { ORPCError } from '@orpc/server'; import { createTestDb, createTestRedis, type TestDb, type TestRedis } from '@openora/core/testing'; import { migrate as migrateIdentity } from '@openora/core/pam/migrate/identity'; +import { player } from '@openora/core/pam/schema/profile'; +import { migrate as migrateProfile } from '@openora/core/pam/migrate/profile'; import type { EmailTemplateRenderer, PlatformConfig, @@ -132,7 +134,7 @@ const signInSuccess = (userId: string) => ); beforeAll(async () => { - db = await createTestDb([migrateIdentity]); + db = await createTestDb([migrateIdentity, migrateProfile]); redis = await createTestRedis(); }); @@ -144,7 +146,9 @@ afterAll(async () => { beforeEach(async () => { vi.clearAllMocks(); getSessionMock.mockResolvedValue(null); - await db.drizzle.db.execute(sql`TRUNCATE ${user}, ${session} RESTART IDENTITY CASCADE`); + await db.drizzle.db.execute( + sql`TRUNCATE ${user}, ${session}, ${player} RESTART IDENTITY CASCADE`, + ); await redis.flush(); }); @@ -455,6 +459,65 @@ describe('IdentityService - RG login gate (real PG)', () => { }); }); +describe('IdentityService - player-status login gate (real PG)', () => { + async function seedBlockedPlayer(status: 'suspended' | 'closed') { + const account = await seedUser(); + await db.drizzle.db.insert(player).values({ userId: account.id, displayName: 'x', status }); + const live = new Date(Date.now() + 24 * 60 * 60 * 1000); + await db.drizzle.db + .insert(session) + .values({ userId: account.id, token: randomUUID(), expiresAt: live }); + return account; + } + + for (const status of ['suspended', 'closed'] as const) { + it(`blocks a ${status} player AFTER credentials verify, expires the issued session, and emits player.login_blocked`, async () => { + const account = await seedBlockedPlayer(status); + const events = makeEventBus(); + // Valid credentials (200) - the gate must still block. + signInEmailMock.mockResolvedValue(signInSuccess(account.id)); + const svc = buildService({ events }); + + await expect( + svc.login({ email: EMAIL, password: 'rightpass1' }, {}, new Headers()), + ).rejects.toMatchObject({ code: 'FORBIDDEN', data: { code: 'ACCOUNT_SUSPENDED' } }); + + expect(signInEmailMock).toHaveBeenCalled(); + expect(events.emit).toHaveBeenCalledWith( + 'player.login_blocked', + expect.objectContaining({ userId: account.id, status }), + ); + expect(events.emit).not.toHaveBeenCalledWith('identity.user.login', expect.anything()); + expect(events.emit).not.toHaveBeenCalledWith('identity.user.login.failed', expect.anything()); + + const [revoked] = await db.drizzle.db + .select() + .from(session) + .where(eq(session.userId, account.id)); + expect(revoked.expiresAt.getTime()).toBeLessThanOrEqual(Date.now()); + }); + } + + it('allows login for an active player (no regression)', async () => { + const account = await seedUser(); + await db.drizzle.db + .insert(player) + .values({ userId: account.id, displayName: 'x', status: 'active' }); + const events = makeEventBus(); + signInEmailMock.mockResolvedValue(signInSuccess(account.id)); + const svc = buildService({ events }); + + const result = await svc.login({ email: EMAIL, password: 'rightpass1' }, {}, new Headers()); + + expect(result).toMatchObject({ session: { token: 'tok' } }); + expect(events.emit).toHaveBeenCalledWith( + 'identity.user.login', + expect.objectContaining({ userId: account.id }), + ); + expect(events.emit).not.toHaveBeenCalledWith('player.login_blocked', expect.anything()); + }); +}); + describe('IdentityService.unlockUser (real PG)', () => { it('clears the lockout row and emits unlocked with the prior state', async () => { const lockedUntil = new Date('2020-01-01T00:00:00.000Z'); diff --git a/packages/core/src/pam/identity/__tests__/phone-login.service.int.test.ts b/packages/core/src/pam/identity/__tests__/phone-login.service.int.test.ts index 4f9bcd6e..19c2e928 100644 --- a/packages/core/src/pam/identity/__tests__/phone-login.service.int.test.ts +++ b/packages/core/src/pam/identity/__tests__/phone-login.service.int.test.ts @@ -5,6 +5,8 @@ import { sql } from 'drizzle-orm'; import { RedisCache } from '@openora/core/server'; import { createTestDb, createTestRedis, type TestDb, type TestRedis } from '@openora/core/testing'; import { migrate as migrateIdentity } from '@openora/core/pam/migrate/identity'; +import { player } from '@openora/core/pam/schema/profile'; +import { migrate as migrateProfile } from '@openora/core/pam/migrate/profile'; import type { Auth } from '@openora/core/server'; import type { CacheAdapter, RateLimiterAdapter, SmsAdapter } from '@openora/core/contracts'; import { PhoneLoginService } from '../service/phone-login.service.js'; @@ -112,7 +114,7 @@ const otpRows = () => db.drizzle.db.select().from(smsOtpSession); const sessionRows = () => db.drizzle.db.select().from(session); beforeAll(async () => { - db = await createTestDb([migrateIdentity]); + db = await createTestDb([migrateIdentity, migrateProfile]); redis = await createTestRedis(); }); @@ -123,7 +125,7 @@ afterAll(async () => { beforeEach(async () => { await db.drizzle.db.execute( - sql`TRUNCATE ${user}, ${session}, ${smsOtpSession} RESTART IDENTITY CASCADE`, + sql`TRUNCATE ${user}, ${session}, ${smsOtpSession}, ${player} RESTART IDENTITY CASCADE`, ); await redis.flush(); }); @@ -459,4 +461,31 @@ describe('PhoneLoginService.verifyOtp (real PG + real Redis)', () => { ); expect(events.emit).not.toHaveBeenCalledWith('identity.user.phone_login', expect.anything()); }); + + it('suspended player is forbidden after the OTP passes, and neither the session nor the OTP is consumed', async () => { + const code = '123456'; + const account = await seedUser(); + await db.drizzle.db + .insert(player) + .values({ userId: account.id, displayName: 'x', status: 'suspended' }); + await seedOtp(account.id, { codeHash: hash(code) }); + const { svc, events } = build(); + const resHeaders = new Headers(); + + await expect( + svc.verifyOtp({ phone: PHONE, code, ...NO_CLIENT_META }, resHeaders), + ).rejects.toMatchObject({ + code: 'FORBIDDEN', + data: { reason: 'account_suspended' }, + }); + + expect(resHeaders.get('set-cookie')).toBeNull(); + expect(await sessionRows()).toHaveLength(0); + expect(await otpRows()).toHaveLength(1); + expect(events.emit).toHaveBeenCalledWith( + 'player.login_blocked', + expect.objectContaining({ userId: account.id, status: 'suspended' }), + ); + expect(events.emit).not.toHaveBeenCalledWith('identity.user.phone_login', expect.anything()); + }); }); diff --git a/packages/core/src/pam/identity/service/identity.service.ts b/packages/core/src/pam/identity/service/identity.service.ts index d1f3106b..d1033675 100644 --- a/packages/core/src/pam/identity/service/identity.service.ts +++ b/packages/core/src/pam/identity/service/identity.service.ts @@ -14,6 +14,7 @@ import { import { parseCookies } from 'better-auth/cookies'; import { eq, sql } from 'drizzle-orm'; import { user, session, account, verification, twoFactor } from '../schema/index.js'; +import { player } from '@openora/core/pam/schema/profile'; import type { CacheAdapter, RateLimiterAdapter, @@ -478,6 +479,34 @@ export class IdentityService { }); } + // Backoffice-initiated account block (status suspended/closed). Same shape as the + // RG gate above: applied only AFTER credentials verify, kills the just-issued + // session and withholds its cookie. Distinct mechanism from RG (self_excluded is + // out of scope here) - a suspended/closed player can never log back in. + if (existingUser) { + const [playerRow] = await this.drizzle.db + .select({ status: player.status }) + .from(player) + .where(eq(player.userId, existingUser.id)) + .limit(1); + if (playerRow && (playerRow.status === 'suspended' || playerRow.status === 'closed')) { + await this.drizzle.db + .update(session) + .set({ expiresAt: new Date() }) + .where(eq(session.userId, existingUser.id)); + this.events.emit('player.login_blocked', { + userId: existingUser.id, + status: playerRow.status, + ip, + userAgent, + }); + throw new ORPCError('FORBIDDEN', { + message: 'This account has been suspended and can no longer be used.', + data: { code: 'ACCOUNT_SUSPENDED' }, + }); + } + } + this.forwardCookies(authResponse, resHeaders); const body = (await authResponse.json()) as { @@ -518,7 +547,9 @@ export class IdentityService { if ( error instanceof ORPCError && error.code === 'FORBIDDEN' && - (error.data as { code?: string } | undefined)?.code === 'RG_BLOCKED' + ['RG_BLOCKED', 'ACCOUNT_SUSPENDED'].includes( + (error.data as { code?: string } | undefined)?.code ?? '', + ) ) { throw error; } diff --git a/packages/core/src/pam/identity/service/phone-login.service.ts b/packages/core/src/pam/identity/service/phone-login.service.ts index 945fbea7..792695e9 100644 --- a/packages/core/src/pam/identity/service/phone-login.service.ts +++ b/packages/core/src/pam/identity/service/phone-login.service.ts @@ -24,6 +24,7 @@ import { ClientMeta, } from '@openora/core/contracts'; import { user, session, smsOtpSession } from '../schema/index.js'; +import { player } from '@openora/core/pam/schema/profile'; import { isRgBlocked } from './rg-guard.service.js'; const MINUTE_MS = 60 * 1000; @@ -339,6 +340,29 @@ export class PhoneLoginService { }); } + // Backoffice-initiated account block (status suspended/closed). Checked after the OTP + // verifies, before the session-minting transaction - no session exists yet, so unlike + // the email path there is nothing to revoke here; the OTP is left unconsumed so the + // gate reads the same as the RG block above. Distinct from RG (self_excluded is out of + // scope) - a suspended/closed player can never complete phone login. + const [playerRow] = await this.drizzle.db + .select({ status: player.status }) + .from(player) + .where(eq(player.userId, account.id)) + .limit(1); + if (playerRow && (playerRow.status === 'suspended' || playerRow.status === 'closed')) { + this.events.emit('player.login_blocked', { + userId: account.id, + status: playerRow.status, + ip, + userAgent, + }); + throw new ORPCError('FORBIDDEN', { + message: 'This account has been suspended and can no longer be used.', + data: { reason: PhoneLoginErrorReasonSchema.enum.account_suspended }, + }); + } + // Mint the session directly - bypasses better-auth's TOTP plugin chain by design, // so phone login never triggers a second factor. // Delete the OTP and insert the new session atomically: if the insert fails the diff --git a/packages/core/src/pam/player-management/__tests__/player.router.int.test.ts b/packages/core/src/pam/player-management/__tests__/player.router.int.test.ts index f70506f9..acaf5969 100644 --- a/packages/core/src/pam/player-management/__tests__/player.router.int.test.ts +++ b/packages/core/src/pam/player-management/__tests__/player.router.int.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; import { randomUUID } from 'node:crypto'; import { eq, sql } from 'drizzle-orm'; import { call } from '@orpc/server'; @@ -10,7 +10,14 @@ import { player } from '@openora/core/pam/schema/profile'; import { migrate as migrateProfile } from '@openora/core/pam/migrate/profile'; import { tag, playerTag } from '@openora/core/pam/schema/tag'; import { migrate as migrateTag } from '@openora/core/pam/migrate/tag'; +import type { + AdminUserDirectory, + AdminGameReporting, + ChatBlockWriter, + SessionCommands, +} from '@openora/core/contracts'; import { + mock, makeAdminGuard, makeAuditWriter, makeEventBus, @@ -41,8 +48,22 @@ beforeEach(async () => { const guardAllowing = (allow: readonly string[]) => makeAdminGuard({ allow, caller: { userId: CALLER } }); -function build(adminGuard: AdminGuard) { - const service = new PlayerService(db.drizzle, makeEventBus()); +function build( + adminGuard: AdminGuard, + overrides: { + userDirectory?: AdminUserDirectory; + gameReporting?: AdminGameReporting; + blockWriter?: ChatBlockWriter; + } = {}, +) { + const service = new PlayerService( + db.drizzle, + makeEventBus(), + overrides.userDirectory ?? mock({}), + overrides.gameReporting ?? mock({}), + overrides.blockWriter ?? mock({}), + mock({ revokeAll: vi.fn().mockResolvedValue({ success: true }) }), + ); const audit = makeAuditWriter(); return { router: createPlayerRouter(service, adminGuard, audit), audit }; } @@ -116,3 +137,39 @@ describe('player router update', () => { ).rejects.toMatchObject({ code: 'NOT_FOUND' }); }); }); + +// playerSearch/playerProfile are player-facing (no adminGuard call) - any +// authenticated caller can reach them, unlike every other route in this router. +describe('player router playerSearch / playerProfile', () => { + const VIEWER = '55555555-5555-4555-8555-555555555555'; + const VIEWER_CTX = testContext({ auth: { userId: VIEWER } }); + + it('rejects an unauthenticated caller with UNAUTHORIZED', async () => { + const { router } = build(guardAllowing([])); + + await expect( + call(router.playerSearch, { q: 'bob', limit: 10 }, { context: CTX }), + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); + + it('does not require the admin guard for an authenticated player', async () => { + const userDirectory = mock({ + findPlayerIds: async () => [], + lookupPlayers: async () => [], + }); + const { router } = build(guardAllowing([]), { userDirectory }); + + await expect( + call(router.playerSearch, { q: 'bob', limit: 10 }, { context: VIEWER_CTX }), + ).resolves.toEqual([]); + }); + + it('maps an unknown profile userId to NOT_FOUND', async () => { + const userDirectory = mock({ lookupPlayers: async () => [] }); + const { router } = build(guardAllowing([]), { userDirectory }); + + await expect( + call(router.playerProfile, { userId: randomUUID() }, { context: VIEWER_CTX }), + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); +}); diff --git a/packages/core/src/pam/player-management/__tests__/player.service.int.test.ts b/packages/core/src/pam/player-management/__tests__/player.service.int.test.ts index 83b1e04c..ba181b64 100644 --- a/packages/core/src/pam/player-management/__tests__/player.service.int.test.ts +++ b/packages/core/src/pam/player-management/__tests__/player.service.int.test.ts @@ -1,7 +1,13 @@ -import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; import { randomUUID } from 'node:crypto'; import { eq, sql } from 'drizzle-orm'; -import type { TagKey } from '@openora/core/contracts'; +import type { + TagKey, + AdminUserDirectory, + AdminGameReporting, + ChatBlockWriter, + SessionCommands, +} from '@openora/core/contracts'; import { createTestDb, type TestDb } from '@openora/core/testing'; import { user } from '@openora/core/pam/schema/identity'; import { migrate as migrateIdentity } from '@openora/core/pam/migrate/identity'; @@ -9,7 +15,7 @@ import { player } from '@openora/core/pam/schema/profile'; import { migrate as migrateProfile } from '@openora/core/pam/migrate/profile'; import { tag, playerTag } from '@openora/core/pam/schema/tag'; import { migrate as migrateTag } from '@openora/core/pam/migrate/tag'; -import { makeEventBus } from '../../../testing/mock.js'; +import { mock, makeEventBus } from '../../../testing/mock.js'; import { PlayerService, PlayerNotFoundError, @@ -18,9 +24,82 @@ import { let db: TestDb; -function makeService() { +function makeService( + overrides: { + userDirectory?: AdminUserDirectory; + gameReporting?: AdminGameReporting; + blockWriter?: ChatBlockWriter; + sessionCommands?: SessionCommands; + } = {}, +) { const events = makeEventBus(); - return { svc: new PlayerService(db.drizzle, events), events }; + const userDirectory = overrides.userDirectory ?? mock({}); + const gameReporting = overrides.gameReporting ?? mock({}); + const blockWriter = overrides.blockWriter ?? mock({}); + const sessionCommands = + overrides.sessionCommands ?? + mock({ revokeAll: vi.fn().mockResolvedValue({ success: true }) }); + return { + svc: new PlayerService( + db.drizzle, + events, + userDirectory, + gameReporting, + blockWriter, + sessionCommands, + ), + events, + sessionCommands, + }; +} + +const SEARCH_VIEWER_ID = '00000000-0000-0000-0000-0000000000a1'; +const SEARCH_OTHER_ID = '00000000-0000-0000-0000-0000000000a2'; +const SEARCH_CREATED_AT = new Date('2026-01-01T00:00:00.000Z'); + +function makeUserDirectory(): AdminUserDirectory { + const all = [ + { + userId: SEARCH_VIEWER_ID, + username: 'bob', + email: 'bob@example.com', + kycStatus: null, + language: null, + avatarUrl: null, + createdAt: SEARCH_CREATED_AT, + level: 3, + currency: 'USD', + }, + { + userId: SEARCH_OTHER_ID, + username: 'alice', + email: 'alice@example.com', + kycStatus: null, + language: null, + avatarUrl: null, + createdAt: SEARCH_CREATED_AT, + level: 1, + currency: 'USD', + }, + ]; + return mock({ + findPlayerIds: vi.fn().mockResolvedValue([SEARCH_VIEWER_ID]), + lookupPlayers: vi.fn().mockImplementation((ids: string[]) => { + return Promise.resolve(all.filter((p) => ids.includes(p.userId))); + }), + }); +} + +function makeGameReporting(): AdminGameReporting { + return mock({ + getPlayerStats: vi.fn().mockResolvedValue({ totalWagered: '100.00000000', totalBets: 5 }), + }); +} + +function makeBlockWriter(excluded: string[] = []): ChatBlockWriter { + return mock({ + getExcludedUserIds: vi.fn().mockResolvedValue(excluded), + }); } async function seedUser(overrides: Partial = {}) { @@ -82,21 +161,32 @@ beforeEach(async () => { ); }); +const ACTOR_ID = '00000000-0000-0000-0000-0000000000ff'; + describe('PlayerService.remove (real PG)', () => { it('soft-deletes by closing the player, never deletes the row', async () => { const { svc } = makeService(); const { player: seeded } = await seedPlayerWithUser(); - const result = await svc.remove(seeded.id); + const result = await svc.remove(seeded.id, ACTOR_ID); expect(result).toEqual({ success: true }); expect(await rowById(seeded.id)).toMatchObject({ status: 'closed' }); }); + it('revokes every session for the removed player, passing the userId and the actor id', async () => { + const { svc, sessionCommands } = makeService(); + const { account, player: seeded } = await seedPlayerWithUser(); + + await svc.remove(seeded.id, ACTOR_ID); + + expect(sessionCommands.revokeAll).toHaveBeenCalledWith(account.id, ACTOR_ID); + }); + it('throws PlayerNotFoundError when the player does not exist', async () => { const { svc } = makeService(); - await expect(svc.remove(randomUUID())).rejects.toBeInstanceOf(PlayerNotFoundError); + await expect(svc.remove(randomUUID(), ACTOR_ID)).rejects.toBeInstanceOf(PlayerNotFoundError); }); }); @@ -283,6 +373,36 @@ describe('PlayerService.update (real PG)', () => { }); }); +describe('PlayerService.update session revocation on block (real PG)', () => { + it('revokes every session when a player transitions active -> suspended, with the userId and actor id', async () => { + const { svc, sessionCommands } = makeService(); + const { account, player: seeded } = await seedPlayerWithUser({}, { status: 'active' }); + + await svc.update(seeded.id, { status: 'suspended' }, ACTOR_ID); + + expect(sessionCommands.revokeAll).toHaveBeenCalledTimes(1); + expect(sessionCommands.revokeAll).toHaveBeenCalledWith(account.id, ACTOR_ID); + }); + + it('does not revoke sessions on a transition to a non-blocking status (dormant)', async () => { + const { svc, sessionCommands } = makeService(); + const { player: seeded } = await seedPlayerWithUser({}, { status: 'active' }); + + await svc.update(seeded.id, { status: 'dormant' }, ACTOR_ID); + + expect(sessionCommands.revokeAll).not.toHaveBeenCalled(); + }); + + it('does not revoke sessions when the status is set to suspended but was already suspended (no transition)', async () => { + const { svc, sessionCommands } = makeService(); + const { player: seeded } = await seedPlayerWithUser({}, { status: 'suspended' }); + + await svc.update(seeded.id, { status: 'suspended' }, ACTOR_ID); + + expect(sessionCommands.revokeAll).not.toHaveBeenCalled(); + }); +}); + describe('PlayerService.update player.level.changed emission (real PG)', () => { it('emits player.level.changed with previousLevel/newLevel/actorId when level changes', async () => { const { svc, events } = makeService(); @@ -316,3 +436,93 @@ describe('PlayerService.update player.level.changed emission (real PG)', () => { expect(events.emit).not.toHaveBeenCalledWith('player.level.changed', expect.anything()); }); }); + +// Ported from chat-commands.service.test.ts (ChatCommandsService.searchPlayers/ +// getPlayerProfile) - these methods never touch the DB, only the injected ports. +describe('PlayerService.searchPlayers', () => { + it('returns mapped player search results', async () => { + const userDirectory = makeUserDirectory(); + const { svc } = makeService({ userDirectory, blockWriter: makeBlockWriter() }); + + const result = await svc.searchPlayers('bo', 5, SEARCH_VIEWER_ID); + + expect(userDirectory.findPlayerIds).toHaveBeenCalledWith('bo', 5); + expect(result).toEqual([ + { userId: SEARCH_VIEWER_ID, username: 'bob', avatarUrl: null, level: 3 }, + ]); + }); + + it('returns empty array when no matches', async () => { + const userDirectory = mock({ + findPlayerIds: vi.fn().mockResolvedValue([]), + lookupPlayers: vi.fn().mockResolvedValue([]), + }); + const { svc } = makeService({ userDirectory }); + + const result = await svc.searchPlayers('xyz', 10, SEARCH_VIEWER_ID); + + expect(result).toEqual([]); + }); + + it('excludes ids the viewer has blocked or ignored', async () => { + const userDirectory = makeUserDirectory(); + const blockWriter = makeBlockWriter([SEARCH_VIEWER_ID]); + const { svc } = makeService({ userDirectory, blockWriter }); + + const result = await svc.searchPlayers('bo', 5, SEARCH_OTHER_ID); + + expect(blockWriter.getExcludedUserIds).toHaveBeenCalledWith(SEARCH_OTHER_ID); + expect(result).toEqual([]); + }); +}); + +describe('PlayerService.getPlayerProfile', () => { + it('returns the full profile card on the happy path (self view)', async () => { + const gameReporting = makeGameReporting(); + const { svc } = makeService({ userDirectory: makeUserDirectory(), gameReporting }); + + const result = await svc.getPlayerProfile(SEARCH_VIEWER_ID, SEARCH_VIEWER_ID); + + expect(gameReporting.getPlayerStats).toHaveBeenCalledWith(SEARCH_VIEWER_ID); + expect(result).toEqual({ + userId: SEARCH_VIEWER_ID, + username: 'bob', + avatarUrl: null, + level: 3, + joinedAt: SEARCH_CREATED_AT.toISOString(), + totalWagered: '100.00000000', + totalBets: 5, + currency: 'USD', + }); + }); + + it('throws PlayerNotFoundError for an unknown userId', async () => { + const userDirectory = mock({ + lookupPlayers: vi.fn().mockResolvedValue([]), + }); + const { svc } = makeService({ userDirectory }); + + await expect(svc.getPlayerProfile(SEARCH_OTHER_ID, SEARCH_VIEWER_ID)).rejects.toBeInstanceOf( + PlayerNotFoundError, + ); + }); + + it('redacts private profile fields for another viewer', async () => { + const gameReporting = makeGameReporting(); + const { svc } = makeService({ userDirectory: makeUserDirectory(), gameReporting }); + + const result = await svc.getPlayerProfile(SEARCH_VIEWER_ID, SEARCH_OTHER_ID); + + expect(result).toMatchObject({ + userId: SEARCH_VIEWER_ID, + username: 'bob', + avatarUrl: null, + level: 3, + joinedAt: null, + totalWagered: null, + totalBets: null, + currency: null, + }); + expect(gameReporting.getPlayerStats).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/pam/player-management/contract/index.ts b/packages/core/src/pam/player-management/contract/index.ts index 4b2876d5..4aeb98df 100644 --- a/packages/core/src/pam/player-management/contract/index.ts +++ b/packages/core/src/pam/player-management/contract/index.ts @@ -8,6 +8,7 @@ import { UuidSchema, TagKeySchema, PaginatedPlayerSearchArgsSchema, + MoneyAmountSchema, } from '@openora/core/contracts'; import { paginated } from '@openora/core/contracts/kit'; @@ -31,6 +32,28 @@ export const PlayerSummarySchema = z.object({ selfExcluded: z.number().int(), }); +// Ported from chat-commands: powers the chat `/profile` command's search step +// and profile card. See AGENTS.md "block/ignore search exclusion". +export const PlayerSearchResultSchema = z.object({ + userId: UuidSchema, + username: z.string(), + avatarUrl: z.string().nullable(), + level: z.number().int(), +}); +export type PlayerSearchResult = z.infer; + +export const PlayerProfileCardSchema = z.object({ + userId: UuidSchema, + username: z.string(), + avatarUrl: z.string().nullable(), + level: z.number().int(), + joinedAt: z.string().nullable(), + totalWagered: MoneyAmountSchema.nullable(), + totalBets: z.number().int().nullable(), + currency: z.string().nullable(), +}); +export type PlayerProfileCard = z.infer; + export const playerContract = populateContractRouterPaths({ list: oc .route({ method: 'GET', path: '/players' }) @@ -77,4 +100,22 @@ export const playerContract = populateContractRouterPaths({ .output(z.array(PlayerRegistrationPointSchema)), summary: oc.route({ method: 'GET', path: '/players/stats/summary' }).output(PlayerSummarySchema), + + // Chat `/profile` command's search step - excludes players the caller has + // blocked/ignored (moderation). See AGENTS.md. + playerSearch: oc + .route({ method: 'GET', path: '/players/search' }) + .input( + z.object({ + q: z.string().min(1).max(50), + limit: z.coerce.number().int().min(1).max(100).default(20), + }), + ) + .output(z.array(PlayerSearchResultSchema)), + + // Looked up by identity userId (like getByUserId), never the PAM playerId. + playerProfile: oc + .route({ method: 'GET', path: '/players/{userId}/profile-card' }) + .input(z.object({ userId: UuidSchema })) + .output(PlayerProfileCardSchema), }); diff --git a/packages/core/src/pam/player-management/plugin.ts b/packages/core/src/pam/player-management/plugin.ts index 059020f4..07a9a1c4 100644 --- a/packages/core/src/pam/player-management/plugin.ts +++ b/packages/core/src/pam/player-management/plugin.ts @@ -1,6 +1,13 @@ import { EVENT_BUS, DRIZZLE, ADMIN_GUARD } from '@openora/core/server'; +import { + AUDIT_WRITER, + KYC_STATUS_WRITER, + ADMIN_USER_DIRECTORY, + ADMIN_GAME_REPORTING, + CHAT_BLOCK_WRITER, + SESSION_COMMANDS, +} from '@openora/core/contracts'; import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; -import { AUDIT_WRITER, KYC_STATUS_WRITER } from '@openora/core/contracts'; import { PlayerService } from './service/player.service.js'; import { PlayerKycStatusWriter } from './service/kyc-status-writer.js'; import { createPlayerRouter } from './router/index.js'; @@ -9,7 +16,7 @@ import { createPlayerRouter } from './router/index.js'; // (compliance + the admin override route consume it). Reads identity via /schema. See ADR-0020. export default { id: 'player-management', - dependsOn: ['audit'], + dependsOn: ['chat', 'gaming', 'audit', 'identity'], register(ctx) { ctx.provide( KYC_STATUS_WRITER, @@ -17,7 +24,14 @@ export default { ); ctx.routers.add('player', (c) => createPlayerRouter( - new PlayerService(c.get(DRIZZLE), c.get(EVENT_BUS)), + new PlayerService( + c.get(DRIZZLE), + c.get(EVENT_BUS), + c.get(ADMIN_USER_DIRECTORY), + c.get(ADMIN_GAME_REPORTING), + c.get(CHAT_BLOCK_WRITER), + c.get(SESSION_COMMANDS), + ), c.get(ADMIN_GUARD), c.get(AUDIT_WRITER), ), diff --git a/packages/core/src/pam/player-management/router/index.ts b/packages/core/src/pam/player-management/router/index.ts index 35a857f1..faeae6ae 100644 --- a/packages/core/src/pam/player-management/router/index.ts +++ b/packages/core/src/pam/player-management/router/index.ts @@ -1,5 +1,5 @@ import { implement } from '@orpc/server'; -import { AdminGuard, mapErrors, type OssContext } from '@openora/core/server'; +import { AdminGuard, mapErrors, getUserId, type OssContext } from '@openora/core/server'; import type { AuditWritePort } from '@openora/core/contracts'; import { playerContract } from '../contract/index.js'; import { PlayerService, PlayerNotFoundError } from '../service/player.service.js'; @@ -70,7 +70,7 @@ export function createPlayerRouter( const { userId: adminId } = await adminGuard.assert(context, 'player', 'ban'); return mapErrors({ NOT_FOUND: PlayerNotFoundError }, async () => { const before = await player.get(input.playerId); - const result = await player.remove(input.playerId); + const result = await player.remove(input.playerId, adminId); await audit.record({ actorId: adminId, actorType: 'admin', @@ -93,5 +93,19 @@ export function createPlayerRouter( await adminGuard.assert(context, 'analytics', 'view'); return player.summary(); }), + + // Player-facing (not adminGuard-gated) - powers the chat `/profile` command, + // same as the routes this replaces in chat-commands. + playerSearch: os.playerSearch.handler(({ input, context }) => { + const viewerId = getUserId(context); + return player.searchPlayers(input.q, input.limit, viewerId); + }), + + playerProfile: os.playerProfile.handler(({ input, context }) => { + const viewerId = getUserId(context); + return mapErrors({ NOT_FOUND: PlayerNotFoundError }, () => + player.getPlayerProfile(input.userId, viewerId), + ); + }), }); } diff --git a/packages/core/src/pam/player-management/service/player.service.ts b/packages/core/src/pam/player-management/service/player.service.ts index f03c6465..d85184d0 100644 --- a/packages/core/src/pam/player-management/service/player.service.ts +++ b/packages/core/src/pam/player-management/service/player.service.ts @@ -15,16 +15,23 @@ import { type TagKey, type PlayerSortBy, type PaginationOptions, + type AdminUserDirectory, + type AdminGameReporting, + type ChatBlockWriter, + type SessionCommands, } from '@openora/core/contracts'; import { eq, ilike, count, or, and, gte, asc, desc, sql, ne, inArray, isNull } from 'drizzle-orm'; import { player } from '@openora/core/pam/schema/profile'; import { user } from '@openora/core/pam/schema/identity'; import { playerTag, tag } from '@openora/core/pam/schema/tag'; import { toPlayer, fetchEmailByUserId } from '../../shared/player-mapper.js'; +import type { PlayerSearchResult, PlayerProfileCard } from '../contract/index.js'; export const PlayerNotFoundError = makeNotFoundError('Player'); export const DuplicateEmailError = makeConflictError('DuplicateEmail', 'Email is already in use'); +const BLOCKING_PLAYER_STATUSES = new Set(['suspended', 'closed']); + function toDateKey(d: Date): string { return d.toISOString().slice(0, 10); } @@ -33,6 +40,10 @@ export class PlayerService { constructor( private readonly drizzle: DrizzleService, private readonly events: EventBus, + private readonly userDirectory: AdminUserDirectory, + private readonly gameReporting: AdminGameReporting, + private readonly blockWriter: ChatBlockWriter, + private readonly sessionCommands: SessionCommands, ) {} async list({ @@ -237,15 +248,29 @@ export class PlayerService { }); } + // A transition into a blocking status must kill every active session immediately - + // AC: "Blocking a player immediately prevents login and all platform activity." + // Session validity is checked on every request (SessionResolver -> better-auth + // getSession), so revoking here cuts off in-progress activity; IdentityService.login + // additionally blocks a fresh login for the same account (mirrors the RG login gate). + if ( + data.status !== undefined && + data.status !== existing.status && + BLOCKING_PLAYER_STATUSES.has(data.status) + ) { + await this.sessionCommands.revokeAll(existing.userId, actorId); + } + return this.fetchOneWithTags(playerId); } - async remove(playerId: Player['id']) { - findOneOrThrow( + async remove(playerId: Player['id'], actorId: User['id']) { + const existing = findOneOrThrow( await this.drizzle.db.select().from(player).where(eq(player.id, playerId)), new PlayerNotFoundError(playerId), ); await this.drizzle.db.update(player).set({ status: 'closed' }).where(eq(player.id, playerId)); + await this.sessionCommands.revokeAll(existing.userId, actorId); return { success: true }; } @@ -295,4 +320,53 @@ export class PlayerService { ]); return { total, active, newLastWeek, selfExcluded }; } + + // Ported from chat-commands' ChatCommandsService.searchPlayers - powers the + // chat `/profile` command's search step. Excludes players the viewer has + // blocked/ignored (moderation) - see AGENTS.md. + async searchPlayers( + q: string, + limit: number, + viewerId: User['id'], + ): Promise { + const ids = await this.userDirectory.findPlayerIds(q, limit); + if (ids.length === 0) { + return []; + } + const excluded = new Set(await this.blockWriter.getExcludedUserIds(viewerId)); + const filteredIds = ids.filter((id) => !excluded.has(id)); + if (filteredIds.length === 0) { + return []; + } + const summaries = await this.userDirectory.lookupPlayers(filteredIds); + return summaries.map((s) => ({ + userId: s.userId, + username: s.username, + avatarUrl: s.avatarUrl, + level: s.level, + })); + } + + // Ported from chat-commands' ChatCommandsService.getPlayerProfile. Wagered + // totals/currency are self-only - a viewer looking at someone else's card + // only sees username/level/avatar. + async getPlayerProfile(userId: User['id'], viewerId: User['id']): Promise { + const summaries = await this.userDirectory.lookupPlayers([userId]); + const summary = summaries.find((s) => s.userId === userId); + if (!summary) { + throw new PlayerNotFoundError(userId); + } + const isSelf = userId === viewerId; + const stats = isSelf ? await this.gameReporting.getPlayerStats(userId) : null; + return { + userId: summary.userId, + username: summary.username, + avatarUrl: summary.avatarUrl, + level: summary.level, + joinedAt: isSelf ? summary.createdAt.toISOString() : null, + totalWagered: stats?.totalWagered ?? null, + totalBets: stats?.totalBets ?? null, + currency: isSelf ? summary.currency : null, + }; + } } diff --git a/packages/core/src/server/auth/permissions.ts b/packages/core/src/server/auth/permissions.ts index a66e705f..dc440e1b 100644 --- a/packages/core/src/server/auth/permissions.ts +++ b/packages/core/src/server/auth/permissions.ts @@ -21,6 +21,8 @@ export const statement = { tag: ['view', 'create', 'delete'] as const, 'chat-room': ['view', 'create', 'update', 'delete'] as const, 'auto-withdrawal-config': ['view', 'update'] as const, + 'chat-command': ['view', 'update'] as const, + 'chat-moderation': ['view'] as const, } as const; export const ac = createAccessControl(statement); @@ -46,6 +48,8 @@ export const adminRole = ac.newRole({ tag: ['view', 'create', 'delete'], 'chat-room': ['view', 'create', 'update', 'delete'], 'auto-withdrawal-config': ['view', 'update'], + 'chat-command': ['view', 'update'], + 'chat-moderation': ['view'], }); export const supportRole = ac.newRole({ diff --git a/packages/core/src/server/runtime/core-token-catalog.ts b/packages/core/src/server/runtime/core-token-catalog.ts index b641f348..b1a76ff0 100644 --- a/packages/core/src/server/runtime/core-token-catalog.ts +++ b/packages/core/src/server/runtime/core-token-catalog.ts @@ -16,6 +16,7 @@ import { ERROR_TRACKING, GAME_ADAPTER, GEO_IP_ADAPTER, + GIFT_COMMANDS, IDENTITY_OPTIONS, IDENTITY_READER, IGAMING_CONFIG, @@ -32,6 +33,7 @@ import { PLATFORM_CONFIG, PLAYER_TAGS, PLAY_ELIGIBILITY, + RAIN_COMMANDS, RATE_LIMITER, REALTIME_CLIENT_AUTHORIZER, REALTIME_TRANSPORT, @@ -68,6 +70,7 @@ const coreTokenCatalog = { EVENT_BUS, GAME_ADAPTER, GEO_IP_ADAPTER, + GIFT_COMMANDS, IDENTITY_OPTIONS, IDENTITY_READER, IGAMING_CONFIG, @@ -84,6 +87,7 @@ const coreTokenCatalog = { PLATFORM_CONFIG, PLAYER_TAGS, PLAY_ELIGIBILITY, + RAIN_COMMANDS, RATE_LIMITER, REALTIME_CLIENT_AUTHORIZER, REALTIME_TRANSPORT, diff --git a/packages/core/src/wallet/__tests__/wallet.service.int.test.ts b/packages/core/src/wallet/__tests__/wallet.service.int.test.ts index 4754d6c1..211d3baa 100644 --- a/packages/core/src/wallet/__tests__/wallet.service.int.test.ts +++ b/packages/core/src/wallet/__tests__/wallet.service.int.test.ts @@ -173,6 +173,17 @@ describe('WalletService.deposit (real PG)', () => { expect(await balanceOf(w.userId)).toBe(11); }); + + it('throws CurrencyMismatchError when the deposit currency differs from the wallet, without calling the PSP or crediting the balance', async () => { + const { svc, psp } = makeService(); + const w = await seedWallet({ balance: '100', currency: 'USD' }); + + await expect( + svc.deposit({ userId: w.userId, amount: '10', currency: 'EUR' }), + ).rejects.toBeInstanceOf(CurrencyMismatchError); + expect(psp.processDeposit).not.toHaveBeenCalled(); + expect(await balanceOf(w.userId)).toBe(100); + }); }); describe('WalletService.withdraw (real PG)', () => { diff --git a/packages/core/src/wallet/router/index.ts b/packages/core/src/wallet/router/index.ts index 27c723a8..8bf83de6 100644 --- a/packages/core/src/wallet/router/index.ts +++ b/packages/core/src/wallet/router/index.ts @@ -33,7 +33,7 @@ export function createWalletRouter( getBalance: os.getBalance.handler(({ context }) => wallet.getBalance(getUserId(context))), deposit: os.deposit.handler(({ input, context }) => - mapErrors({ CONFLICT: IdempotencyKeyReuseError }, () => + mapErrors({ BAD_REQUEST: CurrencyMismatchError, CONFLICT: IdempotencyKeyReuseError }, () => wallet.deposit({ userId: getUserId(context), amount: input.amount, diff --git a/packages/core/src/wallet/service/wallet.service.ts b/packages/core/src/wallet/service/wallet.service.ts index ea1c8c8c..39fd5bee 100644 --- a/packages/core/src/wallet/service/wallet.service.ts +++ b/packages/core/src/wallet/service/wallet.service.ts @@ -321,6 +321,18 @@ export class WalletService { return found.replay; } preResolvedWallet = found.walletRecord; + } else { + [preResolvedWallet] = await this.drizzle.db + .select() + .from(wallet) + .where(eq(wallet.userId, userId)); + } + + // Single-currency wallet: reject a mismatch BEFORE the PSP call so a wrong-currency + // request never charges the vendor. A brand-new wallet has no currency to mismatch + // against yet - it adopts this deposit's currency on insert below. + if (preResolvedWallet && currency.toUpperCase() !== preResolvedWallet.currency.toUpperCase()) { + throw new CurrencyMismatchError(currency, preResolvedWallet.currency); } const psp = await this.payment.processDeposit(amount, currency, { userId, provider }); @@ -336,6 +348,10 @@ export class WalletService { new WalletNotFoundError(userId), ); } + // Single-currency wallet: reject a mismatch rather than coerce it onto the wrong rail. + if (currency.toUpperCase() !== walletRecord.currency.toUpperCase()) { + throw new CurrencyMismatchError(currency, walletRecord.currency); + } const { row, replayed } = await this.insertIdempotentTransaction(txn, { namespace: DEPOSIT_IDEMPOTENCY_NAMESPACE, diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index c753106a..f8ab0ea2 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -54,15 +54,19 @@ "@openora/core/engagement/contracts/chat": ["./src/engagement/chat/contract/index.ts"], "@openora/core/engagement/contracts/chat-commands": ["./src/engagement/chat-commands/contract/index.ts"], "@openora/core/engagement/contracts/notifications": ["./src/engagement/notifications/contract/index.ts"], + "@openora/core/engagement/contracts/social-transfers": ["./src/engagement/social-transfers/contract/index.ts"], "@openora/core/engagement/migrate/chat": ["./src/engagement/chat/migrate.ts"], "@openora/core/engagement/migrate/chat-commands": ["./src/engagement/chat-commands/migrate.ts"], "@openora/core/engagement/migrate/notifications": ["./src/engagement/notifications/migrate.ts"], + "@openora/core/engagement/migrate/social-transfers": ["./src/engagement/social-transfers/migrate.ts"], "@openora/core/engagement/plugins/chat": ["./src/engagement/chat/plugin.ts"], "@openora/core/engagement/plugins/chat-commands": ["./src/engagement/chat-commands/plugin.ts"], "@openora/core/engagement/plugins/notifications": ["./src/engagement/notifications/plugin.ts"], + "@openora/core/engagement/plugins/social-transfers": ["./src/engagement/social-transfers/plugin.ts"], "@openora/core/engagement/schema/chat": ["./src/engagement/chat/schema/index.ts"], "@openora/core/engagement/schema/chat-commands": ["./src/engagement/chat-commands/schema/index.ts"], "@openora/core/engagement/schema/notifications": ["./src/engagement/notifications/schema/index.ts"], + "@openora/core/engagement/schema/social-transfers": ["./src/engagement/social-transfers/schema/index.ts"], "@openora/core/engagement/seed/chat-commands": ["./src/engagement/chat-commands/seed/index.ts"], "@openora/core/engagement/server": ["./src/engagement/server.ts"], "@openora/core/iam": ["./src/iam/index.ts"], diff --git a/packages/testing/src/seed-demo-data.ts b/packages/testing/src/seed-demo-data.ts index 03b4a627..59a6fa73 100644 --- a/packages/testing/src/seed-demo-data.ts +++ b/packages/testing/src/seed-demo-data.ts @@ -5,7 +5,12 @@ import { user } from '@openora/core/pam/schema/identity'; import { player } from '@openora/core/pam/schema/profile'; import { wallet, walletTransaction } from '@openora/core/wallet/schema'; import { game } from '@openora/core/casino/schema/gaming'; -import { chatRoom, chatMessage } from '@openora/core/engagement/schema/chat'; +import { + chatRoom, + chatMessage, + chatUserBlock, + chatUserIgnore, +} from '@openora/core/engagement/schema/chat'; import type { ChatRoomCategory } from '@openora/core/engagement/contracts/chat'; export type SeedAuth = { @@ -225,13 +230,20 @@ const CHAT_ROOMS: readonly ChatRoomSeed[] = [ messages: [ 'Big match tonight - who are you backing?', 'Odds are looking great on the underdog.', + 'Anyone else watching the derby this weekend?', + 'That live-betting swing was wild.', ], }, { slug: 'jackpot-wheel', name: 'Jackpot Wheel', category: 'games-sports', - messages: ['Share your wins here!', 'Jackpot season has begun!'], + messages: [ + 'Share your wins here!', + 'Jackpot season has begun!', + 'So close to the wheel bonus, ugh 😅', + 'Congrats on the big spin!', + ], }, { slug: 'latam', @@ -337,6 +349,20 @@ const CHAT_ROOMS: readonly ChatRoomSeed[] = [ }, ]; +// Global chat (roomId: null) - authored by random seeded players, oldest first. +const GLOBAL_CHAT = [ + 'Good luck everyone! 🍀', + 'Anyone on the new slots tonight?', + 'Big win on Aviator just now 🚀', + 'gl hf all', + 'Tried Sweet Bonanza today, pretty fun', + 'That Book of Dead session was rough 😅', + 'Lightning Roulette hitting hard tonight ⚡', + 'Remember to set your limits, stay safe out there', + 'Who is up for a Gates of Olympus run?', + 'Cashed out just in time, phew', +] as const; + export async function seedDemoData(options: SeedOptions): Promise { const { db, auth, playerCount = 36, windowDays = 90, log = () => {} } = options; const admin = options.admin ?? { @@ -348,6 +374,8 @@ export async function seedDemoData(options: SeedOptions): Promise { const rng = makeRng(0x5eed); log('Clearing existing demo content (player, wallet, transaction, game, chat)...'); + await db.delete(chatUserBlock); + await db.delete(chatUserIgnore); await db.delete(chatMessage); await db.delete(chatRoom); await db.delete(walletTransaction); @@ -376,50 +404,19 @@ export async function seedDemoData(options: SeedOptions): Promise { ); log(`Created ${GAMES.length} games.`); - let roomCount = 0; - if (adminUser) { - const insertedRooms = await db - .insert(chatRoom) - .values( - CHAT_ROOMS.map((r) => ({ - name: r.name, - slug: r.slug, - category: r.category, - isPublic: r.isPublic ?? true, - joinCode: r.joinCode ?? null, - creatorId: r.category === 'private-channels' ? null : adminUser.id, - })), - ) - .returning(); - - const messageRows: (typeof chatMessage.$inferInsert)[] = insertedRooms.flatMap((room) => { - const def = CHAT_ROOMS.find((r) => r.slug === room.slug); - return (def?.messages ?? []).map((content) => ({ - roomId: room.id, - userId: adminUser.id, - username: admin.name, - content, - })); - }); - if (messageRows.length > 0) { - await db.insert(chatMessage).values(messageRows); - } - - roomCount = insertedRooms.length; - log(`Created ${roomCount} chat rooms with demo messages.`); - } - let userCount = adminUser ? 1 : 0; let txCount = 0; const now = Date.now(); const dayMs = 86_400_000; + const players: { id: string; displayName: string }[] = []; for (let i = 0; i < playerCount; i++) { const first = pick(rng, FIRST_NAMES); const last = pick(rng, LAST_NAMES); const displayName = `${first} ${last}`; const email = `player.${i + 1}@demo.igaming.dev`; - const [country, currency] = pick(rng, LOCALES); + const [country] = pick(rng, LOCALES); + const currency = 'USD'; const status = weighted(rng, STATUS_WEIGHTS); const kycStatus = weighted(rng, KYC_WEIGHTS); const level = 1 + Math.floor(rng() * 10); @@ -447,6 +444,7 @@ export async function seedDemoData(options: SeedOptions): Promise { continue; } userCount++; + players.push({ id: playerUser.id, displayName }); const totalDeposits = round2(rng() * 8000 + (level - 1) * 400); const totalWagered = round2(totalDeposits * (1.5 + rng() * 4)); @@ -516,6 +514,83 @@ export async function seedDemoData(options: SeedOptions): Promise { log(`Created ${playerCount} players with wallets and ${txCount} transactions.`); + // Room + global chat messages are authored by the seeded players (falling back to + // admin only if somehow no player got created), so demo chat reads like real activity. + const chatAuthors = + players.length > 0 ? players : adminUser ? [{ id: adminUser.id, displayName: admin.name }] : []; + + let roomCount = 0; + let chatMessageCount = 0; + if (adminUser) { + const insertedRooms = await db + .insert(chatRoom) + .values( + CHAT_ROOMS.map((r) => ({ + name: r.name, + slug: r.slug, + category: r.category, + isPublic: r.isPublic ?? true, + joinCode: r.joinCode ?? null, + creatorId: r.category === 'private-channels' ? null : adminUser.id, + })), + ) + .returning(); + roomCount = insertedRooms.length; + + if (chatAuthors.length > 0) { + const roomMessageRows: (typeof chatMessage.$inferInsert)[] = insertedRooms.flatMap((room) => { + const def = CHAT_ROOMS.find((r) => r.slug === room.slug); + const messages = def?.messages ?? []; + return messages.map((content, idx) => { + const author = pick(rng, chatAuthors); + return { + roomId: room.id, + userId: author.id, + username: author.displayName, + content, + createdAt: new Date(now - (messages.length - idx) * 15 * 60_000), + }; + }); + }); + + const globalMessageRows: (typeof chatMessage.$inferInsert)[] = GLOBAL_CHAT.map( + (content, idx) => { + const author = pick(rng, chatAuthors); + return { + roomId: null, + userId: author.id, + username: author.displayName, + content, + createdAt: new Date(now - (GLOBAL_CHAT.length - idx) * 10 * 60_000), + }; + }, + ); + + const messageRows = [...roomMessageRows, ...globalMessageRows]; + if (messageRows.length > 0) { + await db.insert(chatMessage).values(messageRows); + chatMessageCount = messageRows.length; + } + } + + log(`Created ${roomCount} chat rooms and ${chatMessageCount} chat messages.`); + } + + if (adminUser && players.length >= 2) { + // Deterministic pick of two distinct players: a random index, then a random + // non-zero offset (mod length) so the second index can never land on the first. + const blockedIndex = Math.floor(rng() * players.length); + const offset = 1 + Math.floor(rng() * (players.length - 1)); + const ignoredIndex = (blockedIndex + offset) % players.length; + const blocked = players[blockedIndex]; + const ignored = players[ignoredIndex]; + if (blocked && ignored) { + await db.insert(chatUserBlock).values({ blockerId: adminUser.id, blockedId: blocked.id }); + await db.insert(chatUserIgnore).values({ ignorerId: adminUser.id, ignoredId: ignored.id }); + log(`Admin blocked ${blocked.displayName} and ignored ${ignored.displayName}.`); + } + } + return { adminEmail: admin.email, adminPassword: admin.password,