diff --git a/.rulesync/rules/overview.md b/.rulesync/rules/overview.md index d9a1bda7..c56f296e 100644 --- a/.rulesync/rules/overview.md +++ b/.rulesync/rules/overview.md @@ -62,6 +62,7 @@ extensions.config.ts # the single registry of enabled plugins - **New HTTP route** -> the module's `router/index.ts` via `pnpm gen route `. Player routes resolve the caller from `x-user-id`; admin routes MUST be guarded (next). - **Admin-only route** -> `plugin.ts` resolves `AdminGuard` (`c.get(ADMIN_GUARD)`, seeded by `createApp`) and passes it into the router; `await adminGuard.assert(context)` is the handler's FIRST line. The single admin-enforcement point - never re-implement the role check. - **New DB table** -> a Drizzle `pgTable` in the module's `schema/index.ts`; run `propose-table-change` (MCP) first, then `pnpm regen`. See `db-conventions`. +- **Reference / seed data** (roles, command configs, default tags, static lookup rows) -> `/seed/index.ts` exporting a `seed(db: DrizzleDb): Promise` function. Wire the subpath export (`@openora/core//seed/`) in `packages/core/package.json` + `tsconfig.json`, then import and call from `tools/db/seed.ts`. Use `onConflictDoNothing()` — seed is always idempotent. Never inline seed rows in a migration or in `tools/db/seed.ts` directly. Reference: `iam/seed/`, `pam/tag/seed/`, `engagement/chat-commands/seed/`. - **Reusable Zod schema** -> `packages/core/src/contracts/schemas/.ts`. Module-local schemas in the module's `contract/`. - **Enum / status value set** -> a values + schema + type triple on the contract surface (cross-domain: core `contracts/schemas/`; domain-local: the module's `contract/`), pgEnum derived from the tuple. `conventions` section 3 + `db-conventions` > Enums. - **Cross-module event** -> declare the payload in `domainEventSchemas` (`packages/core/src/contracts/schemas/events.ts`), emit via `EventBus`, subscribe with `ctx.events.on(...)`. ADR-0010; detail in `messaging-and-microservices`. diff --git a/docs/catalog.json b/docs/catalog.json index 3dabed31..ebb03056 100644 --- a/docs/catalog.json +++ b/docs/catalog.json @@ -44,7 +44,8 @@ "chat_room", "chat_room_ban", "chat_room_member", - "chat_user_block" + "chat_user_block", + "chat_user_ignore" ], "routes": [ "chat.banMember", @@ -52,26 +53,49 @@ "chat.createPrivateRoom", "chat.createRoom", "chat.deleteMessage", + "chat.deletePrivateRoom", "chat.deleteRoom", "chat.getConnection", "chat.getGlobalMessages", "chat.getOnlineCount", "chat.getRoom", "chat.getRoomMessages", + "chat.ignoreUser", "chat.joinRoom", "chat.kickMember", "chat.leaveRoom", "chat.listAdminRooms", "chat.listBlockedUsers", + "chat.listIgnoredUsers", "chat.listRoomMembers", "chat.listRooms", "chat.sendGlobalMessage", "chat.sendRoomMessage", "chat.streamMessages", "chat.unblockUser", + "chat.unignoreUser", "chat.updateRoom" ] }, + { + "id": "chat-commands", + "group": "engagement", + "tables": [ + "chat_command_config", + "chat_command_idempotency", + "chat_gift" + ], + "routes": [ + "chat-commands.adminUpdateCommand", + "chat-commands.claimGift", + "chat-commands.execute", + "chat-commands.getGift", + "chat-commands.listCommands", + "chat-commands.mentionSearch", + "chat-commands.playerProfile", + "chat-commands.playerSearch" + ] + }, { "id": "cms", "group": "cms", @@ -320,7 +344,8 @@ "status": "wired", "boundIn": [ "packages/core/src/admin-console/plugin.ts", - "packages/core/src/casino/gaming/plugin.ts" + "packages/core/src/casino/gaming/plugin.ts", + "packages/core/src/engagement/chat-commands/plugin.ts" ] }, { @@ -351,6 +376,8 @@ "boundIn": [ "packages/core/src/admin-console/plugin.ts", "packages/core/src/compliance/plugin.ts", + "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/pam/identity/plugin.ts", "packages/core/src/pam/tag/plugin.ts", @@ -383,6 +410,7 @@ "packages/core/src/admin-console/plugin.ts", "packages/core/src/audit/plugin.ts", "packages/core/src/compliance/plugin.ts", + "packages/core/src/engagement/chat-commands/plugin.ts", "packages/core/src/pam/player-management/plugin.ts", "packages/core/src/wallet/plugin.ts" ] @@ -411,6 +439,26 @@ "packages/core/src/pam/identity/plugin.ts" ] }, + { + "category": "chat-block-writer", + "interface": "ChatBlockWriter", + "token": "CHAT_BLOCK_WRITER", + "status": "wired", + "boundIn": [ + "packages/core/src/engagement/chat-commands/plugin.ts", + "packages/core/src/engagement/chat/plugin.ts" + ] + }, + { + "category": "chat-system-writer", + "interface": "ChatSystemWriter", + "token": "CHAT_SYSTEM_WRITER", + "status": "wired", + "boundIn": [ + "packages/core/src/engagement/chat-commands/plugin.ts", + "packages/core/src/engagement/chat/plugin.ts" + ] + }, { "category": "email", "interface": "SendEmailPort", @@ -566,8 +614,7 @@ "token": "REALTIME_TRANSPORT", "status": "wired", "boundIn": [ - "packages/core/src/compliance/plugin.ts", - "packages/core/src/engagement/chat/plugin.ts" + "packages/core/src/compliance/plugin.ts" ] }, { @@ -606,6 +653,7 @@ "status": "wired", "boundIn": [ "packages/core/src/casino/gaming/plugin.ts", + "packages/core/src/engagement/chat-commands/plugin.ts", "packages/core/src/wallet/plugin.ts" ] }, @@ -621,8 +669,13 @@ } ], "events": [ + "chat.donate.sent", + "chat.gift.claimed", + "chat.gift.sent", "chat.message.sent", "chat.private_room.created", + "chat.private_room.deleted", + "chat.rain.distributed", "chat.room.created", "chat.room.deleted", "chat.room.member.banned", @@ -631,7 +684,9 @@ "chat.room.member.left", "chat.room.updated", "chat.user.blocked", + "chat.user.ignored", "chat.user.unblocked", + "chat.user.unignored", "cms.banner.created", "cms.banner.deleted", "cms.banner.updated", @@ -803,6 +858,10 @@ "name": "BannerSchema", "file": "packages/core/src/cms/contract/index.ts" }, + { + "name": "BlockCommandMetadataSchema", + "file": "packages/core/src/contracts/schemas/chat-command-metadata.ts" + }, { "name": "BlockedUserSchema", "file": "packages/core/src/engagement/chat/contract/index.ts" @@ -843,6 +902,14 @@ "name": "ChangePasswordInputSchema", "file": "packages/core/src/contracts/schemas/identity.ts" }, + { + "name": "ChatCommandDescriptorSchema", + "file": "packages/core/src/engagement/chat-commands/contract/index.ts" + }, + { + "name": "ChatCommandTypeSchema", + "file": "packages/core/src/engagement/chat-commands/contract/index.ts" + }, { "name": "ChatConnectionGrantSchema", "file": "packages/core/src/engagement/chat/contract/index.ts" @@ -851,6 +918,10 @@ "name": "ChatMessageSchema", "file": "packages/core/src/engagement/chat/contract/index.ts" }, + { + "name": "ChatMessageTypeSchema", + "file": "packages/core/src/contracts/schemas/chat-command.ts" + }, { "name": "ChatOnlineCountSchema", "file": "packages/core/src/engagement/chat/contract/index.ts" @@ -875,10 +946,22 @@ "name": "ChatRoomSlugSchema", "file": "packages/core/src/engagement/chat/contract/index.ts" }, + { + "name": "ClaimGiftOutputSchema", + "file": "packages/core/src/engagement/chat-commands/contract/index.ts" + }, { "name": "ClientMetaSchema", "file": "packages/core/src/contracts/schemas/common.ts" }, + { + "name": "CommandConfigSchema", + "file": "packages/core/src/engagement/chat-commands/contract/index.ts" + }, + { + "name": "CommandMetadataSchema", + "file": "packages/core/src/contracts/schemas/chat-command.ts" + }, { "name": "ConversionFunnelSchema", "file": "packages/core/src/analytics/contract/funnel.ts" @@ -927,6 +1010,10 @@ "name": "Disable2faInputSchema", "file": "packages/core/src/contracts/schemas/identity.ts" }, + { + "name": "DonateCommandMetadataSchema", + "file": "packages/core/src/contracts/schemas/chat-command-metadata.ts" + }, { "name": "E164PhoneSchema", "file": "packages/core/src/contracts/schemas/identity.ts" @@ -1039,6 +1126,14 @@ "name": "GgrSeriesSchema", "file": "packages/core/src/analytics/contract/financial.ts" }, + { + "name": "GiftCommandMetadataSchema", + "file": "packages/core/src/contracts/schemas/chat-command-metadata.ts" + }, + { + "name": "GiftStateSchema", + "file": "packages/core/src/engagement/chat-commands/contract/index.ts" + }, { "name": "GrantInputSchema", "file": "packages/core/src/iam/contract/index.ts" @@ -1083,6 +1178,14 @@ "name": "IgamingConfigSchema", "file": "packages/core/src/contracts/schemas/igaming-config.ts" }, + { + "name": "IgnoreCommandMetadataSchema", + "file": "packages/core/src/contracts/schemas/chat-command-metadata.ts" + }, + { + "name": "IgnoredUserSchema", + "file": "packages/core/src/engagement/chat/contract/index.ts" + }, { "name": "InvitationStatusSchema", "file": "packages/core/src/contracts/schemas/iam.ts" @@ -1191,6 +1294,10 @@ "name": "MemberSchema", "file": "packages/core/src/contracts/schemas/identity.ts" }, + { + "name": "MentionResultSchema", + "file": "packages/core/src/engagement/chat-commands/contract/index.ts" + }, { "name": "MessageContentSchema", "file": "packages/core/src/engagement/chat/contract/index.ts" @@ -1299,6 +1406,10 @@ "name": "PlayerNoteSortBySchema", "file": "packages/core/src/pam/player-note/contract/index.ts" }, + { + "name": "PlayerProfileCardSchema", + "file": "packages/core/src/engagement/chat-commands/contract/index.ts" + }, { "name": "PlayerRegistrationPointSchema", "file": "packages/core/src/pam/player-management/contract/index.ts" @@ -1311,6 +1422,10 @@ "name": "PlayerSearchArgsSchema", "file": "packages/core/src/contracts/schemas/player.ts" }, + { + "name": "PlayerSearchResultSchema", + "file": "packages/core/src/engagement/chat-commands/contract/index.ts" + }, { "name": "PlayerSearchSchema", "file": "packages/core/src/admin-console/contract/index.ts" @@ -1355,10 +1470,18 @@ "name": "PositiveMoneyAmountSchema", "file": "packages/core/src/casino/gaming/contract/index.ts" }, + { + "name": "ProfileCommandMetadataSchema", + "file": "packages/core/src/contracts/schemas/chat-command-metadata.ts" + }, { "name": "ProviderSelectionSchema", "file": "packages/core/src/contracts/schemas/igaming-config.ts" }, + { + "name": "RainCommandMetadataSchema", + "file": "packages/core/src/contracts/schemas/chat-command-metadata.ts" + }, { "name": "RegisterInputSchema", "file": "packages/core/src/contracts/schemas/identity.ts" @@ -1487,6 +1610,10 @@ "name": "SubmitKycOutputSchema", "file": "packages/core/src/compliance/contract/index.ts" }, + { + "name": "SystemChatMessageSchema", + "file": "packages/core/src/contracts/schemas/chat-command.ts" + }, { "name": "TagKeySchema", "file": "packages/core/src/contracts/schemas/tag.ts" @@ -1644,7 +1771,9 @@ "httpRoutes": [ "DELETE /backoffice/chat/rooms/{id}", "DELETE /chat/blocks/{blockedId}", + "DELETE /chat/ignores/{ignoredId}", "DELETE /chat/messages/{id}", + "DELETE /chat/rooms/{roomId}", "DELETE /cms/banners/{id}", "DELETE /cms/pages/{id}", "DELETE /compliance/limits/{id}", @@ -1667,9 +1796,15 @@ "GET /backoffice/transactions/{id}", "GET /backoffice/users", "GET /backoffice/users/{userId}", + "GET /chat-command/commands", + "GET /chat-command/gift/{id}", + "GET /chat-command/mention-search", + "GET /chat-command/player-profile/{userId}", + "GET /chat-command/player-search", "GET /chat/blocks", "GET /chat/connection", "GET /chat/global", + "GET /chat/ignores", "GET /chat/online-count", "GET /chat/rooms", "GET /chat/rooms/{roomId}", @@ -1722,13 +1857,17 @@ "GET /wallet/withdrawals", "PATCH /backoffice/chat/rooms/{id}", "PATCH /backoffice/users/{userId}", + "PATCH /chat-command/admin/commands/{key}", "PATCH /iam/roles/{roleId}", "PATCH /identity/profile", "PATCH /players/{playerId}", "PATCH /profile", "POST /backoffice/chat/rooms", + "POST /chat-command/execute", + "POST /chat-command/gift/{id}/claim", "POST /chat/blocks", "POST /chat/global", + "POST /chat/ignores", "POST /chat/rooms/join", "POST /chat/rooms/private", "POST /chat/rooms/{roomId}/ban", diff --git a/extensions.config.ts b/extensions.config.ts index 0bde7790..38daa98a 100644 --- a/extensions.config.ts +++ b/extensions.config.ts @@ -20,6 +20,7 @@ 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: '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. { id: 'profile', path: './packages/core/dist/pam/profile/plugin.js' }, diff --git a/packages/core/package.json b/packages/core/package.json index 6e131bab..eb9b50a1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -254,11 +254,6 @@ "import": "./dist/engagement/server.js", "default": "./dist/engagement/server.js" }, - "./engagement/react": { - "types": "./dist/engagement/react.d.ts", - "import": "./dist/engagement/react.js", - "default": "./dist/engagement/react.js" - }, "./engagement/contracts": { "types": "./dist/engagement/contracts.d.ts", "import": "./dist/engagement/contracts.js", @@ -294,6 +289,31 @@ "import": "./dist/engagement/notifications/plugin.js", "default": "./dist/engagement/notifications/plugin.js" }, + "./engagement/contracts/chat-commands": { + "types": "./dist/engagement/chat-commands/contract/index.d.ts", + "import": "./dist/engagement/chat-commands/contract/index.js", + "default": "./dist/engagement/chat-commands/contract/index.js" + }, + "./engagement/schema/chat-commands": { + "types": "./dist/engagement/chat-commands/schema/index.d.ts", + "import": "./dist/engagement/chat-commands/schema/index.js", + "default": "./dist/engagement/chat-commands/schema/index.js" + }, + "./engagement/plugins/chat-commands": { + "types": "./dist/engagement/chat-commands/plugin.d.ts", + "import": "./dist/engagement/chat-commands/plugin.js", + "default": "./dist/engagement/chat-commands/plugin.js" + }, + "./engagement/migrate/chat-commands": { + "types": "./dist/engagement/chat-commands/migrate.d.ts", + "import": "./dist/engagement/chat-commands/migrate.js", + "default": "./dist/engagement/chat-commands/migrate.js" + }, + "./engagement/seed/chat-commands": { + "types": "./dist/engagement/chat-commands/seed/index.d.ts", + "import": "./dist/engagement/chat-commands/seed/index.js", + "default": "./dist/engagement/chat-commands/seed/index.js" + }, "./iam": { "types": "./dist/iam/index.d.ts", "import": "./dist/iam/index.js", @@ -582,13 +602,16 @@ "zod": "4.4.3" }, "devDependencies": { - "@tanstack/react-query": "5.101.0", + "@tanstack/react-query": "5.101.4", + "@testing-library/dom": "10.4.1", + "@testing-library/react": "16.3.2", "@turbo/gen": "2.9.16", "@types/node": "25.9.2", "@types/pg": "8.20.0", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", "drizzle-kit": "0.31.10", + "jsdom": "29.1.1", "typescript": "6.0.3", "vitest": "4.1.8" }, diff --git a/packages/core/src/audit/plugin.ts b/packages/core/src/audit/plugin.ts index ebb45cb0..2eae0be4 100644 --- a/packages/core/src/audit/plugin.ts +++ b/packages/core/src/audit/plugin.ts @@ -255,6 +255,17 @@ function mapEventToRecord(topic: string, p: Record): RecordInpu }; } + // actorId = the player who (un)ignored; resource = the ignored player. + if (topic === 'chat.user.ignored' || topic === 'chat.user.unignored') { + return { + ...base, + actorType: 'player', + actorId: str(p['ignorerId']), + resourceType: 'player', + resourceId: str(p['ignoredId']), + }; + } + // actorId = the moderator who acted; resource = the affected player in that room. if (topic === 'chat.room.member.kicked' || topic === 'chat.room.member.banned') { const actorKey = topic === 'chat.room.member.kicked' ? 'kickedBy' : 'bannedBy'; @@ -268,8 +279,8 @@ function mapEventToRecord(topic: string, p: Record): RecordInpu }; } - // actorId = the player who created the room; resource = the new room. - if (topic === 'chat.private_room.created') { + // actorId = the player who created/deleted the room; resource = the room. + if (topic === 'chat.private_room.created' || topic === 'chat.private_room.deleted') { return { ...base, actorType: 'player', @@ -495,11 +506,12 @@ const SUBSCRIBED_TOPICS: DomainEventName[] = [ 'wallet.withdrawal.failed', 'gaming.round.started', 'gaming.round.ended', - // chat.message.sent is intentionally NOT audited: it is high-volume content - // already persisted in chatMessage; the moderation/block actions are what we audit. 'chat.user.blocked', 'chat.user.unblocked', + 'chat.user.ignored', + 'chat.user.unignored', 'chat.private_room.created', + 'chat.private_room.deleted', 'chat.room.created', 'chat.room.updated', 'chat.room.deleted', @@ -507,6 +519,9 @@ const SUBSCRIBED_TOPICS: DomainEventName[] = [ 'chat.room.member.left', 'chat.room.member.kicked', 'chat.room.member.banned', + // chat.gift.sent + // chat.rain.distributed + 'chat.user.mentioned', 'compliance.limit.upserted', 'compliance.limit.removed', 'rg.limit.set', diff --git a/packages/core/src/casino/gaming/__tests__/gaming.service.test.ts b/packages/core/src/casino/gaming/__tests__/gaming.service.test.ts index 907b4d0c..55673243 100644 --- a/packages/core/src/casino/gaming/__tests__/gaming.service.test.ts +++ b/packages/core/src/casino/gaming/__tests__/gaming.service.test.ts @@ -39,7 +39,7 @@ function makeService({ endRound: vi.fn(), }), playEligibility = unrestricted, - walletCommands = makeWalletCommands({ ok: true, newBalance: '0' }), + walletCommands = makeWalletCommands({ ok: true, newBalance: '0', currency: 'USD' }), }: { provider?: GameAdapter; playEligibility?: PlayEligibilityPort; @@ -123,7 +123,7 @@ describe('GamingService.startRound (real PG)', () => { it('debits the stake and persists the round on sufficient balance', async () => { const created = await seedGame({ id: '00000000-0000-0000-0000-0000000000a1', name: 'Aces' }); - const walletCommands = makeWalletCommands({ ok: true, newBalance: '90' }); + const walletCommands = makeWalletCommands({ ok: true, newBalance: '90', currency: 'USD' }); const launchGame = vi.fn().mockResolvedValue({ launchUrl: 'https://mock/play', token: 'tok' }); const svc = makeService({ provider: mock({ launchGame, endRound: vi.fn() }), diff --git a/packages/core/src/casino/gaming/admin-reporting.ts b/packages/core/src/casino/gaming/admin-reporting.ts index 2632afd9..ce7120b1 100644 --- a/packages/core/src/casino/gaming/admin-reporting.ts +++ b/packages/core/src/casino/gaming/admin-reporting.ts @@ -2,6 +2,7 @@ import type { AdminGameReporting, GamePerformanceFilter, GamePerformanceRow, + PlayerGameStats, } from '@openora/core/contracts'; import { DrizzleService } from '@openora/core/server'; import { and, asc, desc, eq, gte, lte, sql } from 'drizzle-orm'; @@ -71,4 +72,19 @@ export class DrizzleAdminGameReporting implements AdminGameReporting { roundsPlayed: Number(r.roundsPlayed), })); } + + async getPlayerStats(userId: string): Promise { + const db = this.drizzle.db; + const [row] = await db + .select({ + totalWagered: sql`coalesce(sum(${gameRound.betAmount}), 0)`, + totalBets: sql`count(${gameRound.id})`, + }) + .from(gameRound) + .where(and(eq(gameRound.userId, userId), eq(gameRound.status, 'completed'))); + return { + totalWagered: row?.totalWagered ?? '0', + totalBets: Number(row?.totalBets ?? 0), + }; + } } diff --git a/packages/core/src/compliance/drizzle/migrations/0004_military_silk_fever.sql b/packages/core/src/compliance/drizzle/migrations/0004_military_silk_fever.sql index 447fb482..c8d5568e 100644 --- a/packages/core/src/compliance/drizzle/migrations/0004_military_silk_fever.sql +++ b/packages/core/src/compliance/drizzle/migrations/0004_military_silk_fever.sql @@ -1,2 +1,2 @@ DROP INDEX "kyc_verification_reference_id_idx";--> statement-breakpoint -CREATE UNIQUE INDEX "kyc_verification_reference_id_key" ON "kyc_verification" USING btree ("reference_id"); \ No newline at end of file +CREATE UNIQUE INDEX "kyc_verification_reference_id_key" ON "kyc_verification" USING btree ("reference_id"); diff --git a/packages/core/src/contracts/adapters/admin-game-reporting.ts b/packages/core/src/contracts/adapters/admin-game-reporting.ts index 91c7c5c3..642a2a83 100644 --- a/packages/core/src/contracts/adapters/admin-game-reporting.ts +++ b/packages/core/src/contracts/adapters/admin-game-reporting.ts @@ -39,8 +39,14 @@ export type GamePerformanceRow = { roundsPlayed: number; }; +export type PlayerGameStats = { + totalWagered: string; + totalBets: number; +}; + export type AdminGameReporting = { listGamePerformance(filter: GamePerformanceFilter): Promise; + getPlayerStats(userId: string): Promise; }; export const ADMIN_GAME_REPORTING: Token = createToken('ADMIN_GAME_REPORTING'); diff --git a/packages/core/src/contracts/adapters/admin-user-directory.ts b/packages/core/src/contracts/adapters/admin-user-directory.ts index 69e0a3fa..529fa9de 100644 --- a/packages/core/src/contracts/adapters/admin-user-directory.ts +++ b/packages/core/src/contracts/adapters/admin-user-directory.ts @@ -46,6 +46,10 @@ export type AdminPlayerSummary = { email: string; kycStatus: KycStatus | null; language: string | null; + avatarUrl: string | null; + createdAt: Date; + level: number; + currency: string; }; export type AdminUserDirectory = { @@ -64,7 +68,14 @@ export type AdminUserDirectory = { lookupPlayers(userIds: readonly string[]): Promise; // Resolves a free-text player filter to a capped set of userIds, matched against // email (user table) OR username/displayName (player table). Empty = no match. - findPlayerIds(query: string): Promise; + // limit caps both sub-queries and the merged set; defaults to 1000 (the implementation cap). + findPlayerIds(query: string, limit?: number): Promise; + // Exact-match resolution by display name (case-insensitive) - for callers that + // already have a complete, known username (not a partial search term), eg chat + // commands' /donate, /block, /ignore. Distinct from findPlayerIds' capped fuzzy + // substring search: a short/common username can substring-collide with more than + // the 20-row cap of unrelated accounts and silently drop the real exact match. + getPlayerByUsername(username: string): Promise; }; export const ADMIN_USER_DIRECTORY: Token = createToken('ADMIN_USER_DIRECTORY'); diff --git a/packages/core/src/contracts/adapters/chat-block-writer.ts b/packages/core/src/contracts/adapters/chat-block-writer.ts new file mode 100644 index 00000000..0e98c799 --- /dev/null +++ b/packages/core/src/contracts/adapters/chat-block-writer.ts @@ -0,0 +1,11 @@ +import { createToken, type Token } from './token.js'; + +export type ChatBlockWriter = { + blockUser(blockerId: string, blockedId: string): Promise; + ignoreUser(ignorerId: string, ignoredId: string): Promise; + // Union of blocked + ignored ids for a viewer - chat-commands uses this to keep + // blocked/ignored players out of player search results. + getExcludedUserIds(viewerId: string): Promise; +}; + +export const CHAT_BLOCK_WRITER: Token = createToken('CHAT_BLOCK_WRITER'); diff --git a/packages/core/src/contracts/adapters/chat-system-writer.ts b/packages/core/src/contracts/adapters/chat-system-writer.ts new file mode 100644 index 00000000..c3cd9117 --- /dev/null +++ b/packages/core/src/contracts/adapters/chat-system-writer.ts @@ -0,0 +1,15 @@ +import { createToken } from './token.js'; +import type { SystemChatMessage, CommandMetadata } from '../schemas/chat-command.js'; + +export type { SystemChatMessage as ChatSystemMessage }; + +export type ChatSystemWriter = { + postSystemMessage(args: { + roomId: string | null; + actorId: string; + metadata: CommandMetadata; + tx?: unknown; + }): Promise; +}; + +export const CHAT_SYSTEM_WRITER = createToken('ChatSystemWriter'); diff --git a/packages/core/src/contracts/adapters/index.ts b/packages/core/src/contracts/adapters/index.ts index d9991709..4d938706 100644 --- a/packages/core/src/contracts/adapters/index.ts +++ b/packages/core/src/contracts/adapters/index.ts @@ -71,7 +71,12 @@ export type { RealtimeClientAuthorizer, RealtimeClientAuthorizerInput, } from './realtime.js'; -export { REALTIME_TRANSPORT, REALTIME_CLIENT_AUTHORIZER } from './realtime.js'; +export { + REALTIME_TRANSPORT, + REALTIME_CLIENT_AUTHORIZER, + CHAT_REALTIME_TRANSPORT, + CHAT_REALTIME_CLIENT_AUTHORIZER, +} from './realtime.js'; export type { GameAdapter } from './game.js'; export { GAME_ADAPTER } from './game.js'; @@ -159,6 +164,7 @@ export type { GamePerformanceFilter, GamePerformanceRow, AdminGameReporting, + PlayerGameStats, } from './admin-game-reporting.js'; export { GAME_PERFORMANCE_SORT_FIELDS, ADMIN_GAME_REPORTING } from './admin-game-reporting.js'; @@ -182,3 +188,9 @@ export type { LoginEnforcementPort } from './login-enforcement.js'; export { LOGIN_ENFORCEMENT } from './login-enforcement.js'; export type { PlayEligibilityPort } from './play-eligibility.js'; export { PLAY_ELIGIBILITY } from './play-eligibility.js'; + +export type { ChatSystemMessage, ChatSystemWriter } from './chat-system-writer.js'; +export { CHAT_SYSTEM_WRITER } from './chat-system-writer.js'; + +export type { ChatBlockWriter } from './chat-block-writer.js'; +export { CHAT_BLOCK_WRITER } from './chat-block-writer.js'; diff --git a/packages/core/src/contracts/adapters/realtime.ts b/packages/core/src/contracts/adapters/realtime.ts index 2caaea48..7fada7f3 100644 --- a/packages/core/src/contracts/adapters/realtime.ts +++ b/packages/core/src/contracts/adapters/realtime.ts @@ -8,6 +8,16 @@ // the inter-module MESSAGE_BROKER (ADR-0010 #4 keeps client push separate from // the event broker, which has delivery guarantees/acks). Money and moderation // stay first-party domain logic; they are never delegated to the transport. +// +// CHAT_REALTIME_TRANSPORT / CHAT_REALTIME_CLIENT_AUTHORIZER are the SAME two +// port shapes, scoped to chat only. The chat module defaults them to whatever +// REALTIME_TRANSPORT/REALTIME_CLIENT_AUTHORIZER resolve to (so out of the box +// chat behaves identically to every other realtime consumer), but an operator +// can rebind just the CHAT_-prefixed tokens to run chat on a managed vendor +// (eg Ably) while KYC status updates and everything else stay first-party SSE +// - the two seams are independently swappable. `chat-commands` shares the SAME +// chat channels (gift claims, rain), so it consumes CHAT_REALTIME_TRANSPORT +// too, never the generic token. import { createToken, type Token } from './token.js'; // Optional presence capability. A first-party transport can offer a simple @@ -31,10 +41,17 @@ export type RealtimeTransport = { // Revoke managed-provider credentials for a client after access is removed. revokeClient?: (clientId: string) => void | Promise; presence?: RealtimePresence; + // Returns the set of authenticated user IDs currently online in `channel`. + // Anonymous connections (memberIds beginning with 'anonymous:') are excluded. + getOnlineUserIds(channel: string): Promise; }; export const REALTIME_TRANSPORT: Token = createToken('REALTIME_TRANSPORT'); +// Chat-scoped realtime transport - see the module header comment above. +export const CHAT_REALTIME_TRANSPORT: Token = + createToken('CHAT_REALTIME_TRANSPORT'); + // Client connection provisioning - the complement to REALTIME_TRANSPORT. // // REALTIME_TRANSPORT is how the SERVER fans a message out. This seam is how a @@ -82,3 +99,8 @@ export type RealtimeClientAuthorizer = { export const REALTIME_CLIENT_AUTHORIZER: Token = createToken( 'REALTIME_CLIENT_AUTHORIZER', ); + +// Chat-scoped client authorizer - see the module header comment above. +export const CHAT_REALTIME_CLIENT_AUTHORIZER: Token = createToken( + 'CHAT_REALTIME_CLIENT_AUTHORIZER', +); diff --git a/packages/core/src/contracts/adapters/wallet-commands.ts b/packages/core/src/contracts/adapters/wallet-commands.ts index f83137cd..27d660fc 100644 --- a/packages/core/src/contracts/adapters/wallet-commands.ts +++ b/packages/core/src/contracts/adapters/wallet-commands.ts @@ -10,7 +10,7 @@ export type WalletDebitArgs = { }; export type WalletDebitOutcome = - | { ok: true; newBalance: string } + | { ok: true; newBalance: string; currency: string } | { ok: false; available: string }; export type WalletCreditArgs = { diff --git a/packages/core/src/contracts/schemas/chat-command-metadata.ts b/packages/core/src/contracts/schemas/chat-command-metadata.ts new file mode 100644 index 00000000..fca1a247 --- /dev/null +++ b/packages/core/src/contracts/schemas/chat-command-metadata.ts @@ -0,0 +1,53 @@ +import * as z from 'zod'; +import { UuidSchema, MoneyAmountSchema } from './common.js'; + +export const ProfileCommandMetadataSchema = z.object({ + command: z.literal('profile'), + targetUserId: UuidSchema, + displayName: z.string(), + level: z.number().int(), +}); +export type ProfileCommandMetadata = z.infer; + +export const GiftCommandMetadataSchema = z.object({ + command: z.literal('gift'), + giftId: UuidSchema, + senderId: UuidSchema, + senderUsername: z.string(), + amount: MoneyAmountSchema, + currency: z.string(), +}); +export type GiftCommandMetadata = z.infer; + +export const RainCommandMetadataSchema = z.object({ + command: z.literal('rain'), + fromUserId: UuidSchema, + amount: MoneyAmountSchema, + currency: z.string(), + recipientCount: z.number().int(), + perRecipient: MoneyAmountSchema, +}); +export type RainCommandMetadata = z.infer; + +export const BlockCommandMetadataSchema = z.object({ + command: z.literal('block'), + targetUserId: UuidSchema, + displayName: z.string(), +}); +export type BlockCommandMetadata = z.infer; + +export const IgnoreCommandMetadataSchema = z.object({ + command: z.literal('ignore'), + targetUserId: UuidSchema, + displayName: z.string(), +}); +export type IgnoreCommandMetadata = z.infer; + +export const DonateCommandMetadataSchema = z.object({ + command: z.literal('donate'), + recipientId: UuidSchema, + recipientUsername: z.string(), + amount: MoneyAmountSchema, + currency: z.string(), +}); +export type DonateCommandMetadata = z.infer; diff --git a/packages/core/src/contracts/schemas/chat-command.ts b/packages/core/src/contracts/schemas/chat-command.ts new file mode 100644 index 00000000..f92d7c63 --- /dev/null +++ b/packages/core/src/contracts/schemas/chat-command.ts @@ -0,0 +1,39 @@ +import * as z from 'zod'; +import { UuidSchema } from './common.js'; +import { + ProfileCommandMetadataSchema, + GiftCommandMetadataSchema, + RainCommandMetadataSchema, + BlockCommandMetadataSchema, + IgnoreCommandMetadataSchema, + DonateCommandMetadataSchema, +} from './chat-command-metadata.js'; + +export const CHAT_MESSAGE_TYPES = ['user', 'system'] as const; +export const ChatMessageTypeSchema = z.enum(CHAT_MESSAGE_TYPES); +export type ChatMessageType = z.infer; + +export const CommandMetadataSchema = z.discriminatedUnion('command', [ + ProfileCommandMetadataSchema, + GiftCommandMetadataSchema, + RainCommandMetadataSchema, + BlockCommandMetadataSchema, + IgnoreCommandMetadataSchema, + DonateCommandMetadataSchema, +]); +export type CommandMetadata = z.infer; + +export const SystemChatMessageSchema = z.object({ + id: UuidSchema, + roomId: UuidSchema.nullable(), + actorId: UuidSchema, + content: z.string(), + metadata: CommandMetadataSchema, + createdAt: z.string(), +}); +export type SystemChatMessage = z.infer; + +/** 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 75f02c1e..b9918ea5 100644 --- a/packages/core/src/contracts/schemas/events.ts +++ b/packages/core/src/contracts/schemas/events.ts @@ -141,6 +141,8 @@ export const domainEventSchemas = { // A player muted/unmuted another player's messages for themselves (ABC-45 AC11). 'chat.user.blocked': authContextBase.extend({ blockerId: UuidSchema, blockedId: UuidSchema }), 'chat.user.unblocked': authContextBase.extend({ blockerId: UuidSchema, blockedId: UuidSchema }), + 'chat.user.ignored': authContextBase.extend({ ignorerId: UuidSchema, ignoredId: UuidSchema }), + 'chat.user.unignored': authContextBase.extend({ ignorerId: UuidSchema, ignoredId: UuidSchema }), // Admin room CRUD (actorId = acting admin UUID). 'chat.room.created': authContextBase.extend({ @@ -167,6 +169,10 @@ export const domainEventSchemas = { roomId: UuidSchema, creatorId: UuidSchema, }), + 'chat.private_room.deleted': authContextBase.extend({ + roomId: UuidSchema, + creatorId: UuidSchema, + }), 'chat.room.member.joined': authContextBase.extend({ roomId: UuidSchema, userId: UuidSchema }), 'chat.room.member.left': authContextBase.extend({ roomId: UuidSchema, userId: UuidSchema }), 'chat.room.member.kicked': authContextBase.extend({ @@ -312,8 +318,48 @@ export const domainEventSchemas = { after: z.record(z.string(), z.unknown()), }), - // A player's level changed via the admin PlayerService.update() path. actorId is the - // acting admin. Consumed by the tag module to keep the single mutable `level` tag in sync. + // Chat command events. amount is a decimal string; giftId is the chat_gift row id. + 'chat.gift.sent': z.object({ + giftId: UuidSchema, + senderId: UuidSchema, + senderUsername: z.string(), + amount: MoneyAmountSchema, + currency: z.string(), + roomId: UuidSchema, + messageId: UuidSchema, + }), + 'chat.gift.claimed': z.object({ + giftId: UuidSchema, + claimerId: UuidSchema, + claimerUsername: z.string(), + senderId: UuidSchema, + amount: MoneyAmountSchema, + currency: z.string(), + roomId: UuidSchema, + }), + 'chat.rain.distributed': z.object({ + fromUserId: UuidSchema, + recipients: z.array(UuidSchema), + recipientCount: z.number().int(), + totalAmount: MoneyAmountSchema, + currency: z.string(), + roomId: UuidSchema, + }), + 'chat.donate.sent': z.object({ + senderId: UuidSchema, + senderUsername: z.string(), + recipientId: UuidSchema, + recipientUsername: z.string(), + amount: MoneyAmountSchema, + currency: z.string(), + roomId: UuidSchema.nullable(), + }), + 'chat.user.mentioned': z.object({ + mentionedUserId: UuidSchema, + byUserId: UuidSchema, + roomId: UuidSchema.nullable(), + messageId: z.string(), + }), 'player.level.changed': z.object({ userId: UuidSchema, previousLevel: z.number().int(), @@ -334,6 +380,10 @@ export const domainEventVersions: Partial> = { // v2: sessionToken (the raw bearer credential) replaced with sessionId - the token // 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, + // v2: recipients array added for per-player notification delivery. + 'chat.rain.distributed': 2, // 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/index.ts b/packages/core/src/contracts/schemas/index.ts index 963016b1..5adb0947 100644 --- a/packages/core/src/contracts/schemas/index.ts +++ b/packages/core/src/contracts/schemas/index.ts @@ -10,3 +10,5 @@ export * from './tag.js'; export * from './platform-config.js'; export * from './events.js'; export * from './reporting.js'; +export * from './chat-command-metadata.js'; +export * from './chat-command.js'; diff --git a/packages/core/src/contracts/schemas/wallet-tx.ts b/packages/core/src/contracts/schemas/wallet-tx.ts index 8b5d397d..adcc467a 100644 --- a/packages/core/src/contracts/schemas/wallet-tx.ts +++ b/packages/core/src/contracts/schemas/wallet-tx.ts @@ -15,6 +15,8 @@ export const WALLET_TRANSACTION_TYPES = [ 'loss', 'bonus', 'tip', + 'gift', + 'rain', ] as const; export const WALLET_TRANSACTION_STATUSES = [ 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..d5d64dfc --- /dev/null +++ b/packages/core/src/engagement/chat-commands/AGENTS.md @@ -0,0 +1,98 @@ +# chat-commands + +Player-facing slash commands (`/profile`, `/gift`, `/rain`) and `@mention` for the chat UI. Commands are stored in `chat_command_config` keyed by command type; operators can toggle or reconfigure any command via `adminUpdateCommand` without a deploy. + +## DB-backed command registry + +Each row in `chat_command_config` holds `enabled`, `label`, `description`, and a `config` jsonb column (`maxAmount`, `minAmount`, `maxRecipients`). The service checks the row before dispatching — a missing or disabled row throws `CommandDisabledError` (404). Seed data lives in `seed/index.ts` (`seedChatCommands`), called from `tools/db/seed.ts`. Four default commands: `mention`, `profile`, `gift`, `rain`. + +`mention` is special: it does not go through `POST /chat-command/execute`. The `@username` pattern is typed inline in a message; `GET /chat-command/mention-search` powers the type-ahead. The registry entry exists so operators can disable @mentions platform-wide. + +`profile` is also special like `mention` - it does not go through `POST /chat-command/execute`; `GET /chat-command/player-search` powers the search step and `GET /chat-command/player-profile/{userId}` (lookup by userId only, never username) returns the full profile card. Neither route posts to chat. + +`/block` and `/ignore` write to separate tables (`chatUserBlock` vs `chatUserIgnore`, owned by `chat`) via `CHAT_BLOCK_WRITER.blockUser`/`ignoreUser` respectively - they used to both call `blockUser`, now `handleBlockAction` dispatches on `input.type`. Both self-actions (blocking/ignoring yourself) are rejected via `SelfModerationActionError` (409), checked after the target username resolves. `searchPlayers`/`searchMentions` both take a `viewerId` and exclude any player the caller has blocked or ignored via `CHAT_BLOCK_WRITER.getExcludedUserIds(viewerId)` - a blocked/ignored player will not surface in player search or @mention autocomplete. + +## Idempotency for money-moving commands (gift/rain/donate) + +`gift`/`rain`/`donate` REQUIRE a client `idempotencyKey` (uuid) - it is mandatory on the contract, +not optional, so every money-moving request carries a retry guard; a timeout followed by a plain +client retry must never double-debit. Backed by the `chat_command_idempotency` table (unique on +`actorId, commandType, idempotencyKey`). The row IS the atomic guard - `guardCommandIdempotency` +inserts it (with `result: null`) as the FIRST statement inside the same money-moving transaction, +before any debit; `onConflictDoNothing` means a concurrent duplicate loses the race and throws +`ConcurrentCommandReplayError` rather than touching money. The final `ChatSystemMessage` is +backfilled onto the row right before the transaction returns - the same +insert-placeholder-then-backfill idiom `chatGift.messageId` uses. Before entering the transaction, +`findCommandReplay` checks for an existing row: comparison is against `fingerprint`, a sha256 hash +of the FULL request (type, amount, roomId, and any command-specific field like `recipientCount`/ +`targetUsername`) computed by `fingerprintCommand` - NOT just the amount. A matching fingerprint is +a genuine replay (returns the stored result, no wallet call at all); a matching key with a +DIFFERENT fingerprint is a reused key, not a replay (`ChatCommandIdempotencyKeyReuseError`) - +comparing only the amount would let the same key/amount replay into a different room, recipient +count, or donate target. `result` is jsonb rather than a foreign read because chat-commands +doesn't own `chatMessage` (the `chat` module does). + +## Publish-after-commit for gift/rain/donate + +`ChatSystemWriter.postSystemMessage` only auto-publishes to realtime when it owns the write (no +`tx` argument). `handleGift`/`handleRain`/`handleDonate` 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). +`handleBlockAction` does not pass `tx`, so it still relies on `postSystemMessage`'s own auto-publish. + +## Claimable gift mechanic + +`/gift ` is a two-step flow: the sender is debited immediately and a `chat_gift` row (status: unclaimed) is created atomically with the system message. Any other player calls `POST /chat-command/gift/:id/claim` to win the credit. The atomic claim uses `UPDATE ... WHERE claimed_by IS NULL RETURNING *` — first caller wins, zero balance goes unreturned. Realtime push fires on claim via `CHAT_REALTIME_TRANSPORT.publish(chatChannel(roomId), ...)` so frontends can update the card live. + +`chatGift.messageId` is a plain UUID with no FK — cross-module boundary rule applies. + +## Rain split 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). `handleRain` 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 metadata, audit +`after.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. + +## Exact username resolution for `/donate`, `/block`, `/ignore` + +These three commands resolve an EXACT, already-known username (typed in full or picked from +autocomplete, never a partial search term) via the shared `resolveExactPlayer` helper, which calls +`ADMIN_USER_DIRECTORY.getPlayerByUsername` - a real exact, case-insensitive match. They do 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`. `searchMentions`/`searchPlayers` still +use `findPlayerIds` correctly - those genuinely are partial-query autocomplete search. + +## Ports consumed + +- `CHAT_SYSTEM_WRITER` — posts system messages into the chat stream (bound by the `chat` plugin; implemented by `ChatService.sendSystemMessage`). +- `WALLET_COMMANDS` — debits the actor and credits recipient(s) within a single transaction; money never flows over events. +- `ADMIN_USER_DIRECTORY` — `findPlayerIds` for autocomplete-style username search, `lookupPlayers` for batch profile resolution, `getPlayerByUsername` for exact-match resolution of an already-known username (`/donate`, `/block`, `/ignore`). +- `ADMIN_GAME_REPORTING` — `getPlayerStats` for total-wagered/total-bets on the profile card (owned by casino/gaming). +- `AUDIT_WRITER` — direct `record()` after each gift send and gift claim for the regulatory trail. +- `CHAT_REALTIME_TRANSPORT` — `getOnlineUserIds(channel)` for rain recipient discovery; `publish(channel, event)` for gift-claimed push. The chat-scoped token, not the generic `REALTIME_TRANSPORT` - this module publishes on the same `chat:*` channels chat itself uses, so it must ride whatever transport chat is bound to (see `chat/AGENTS.md`), never a different one. + +## Extension points + +Add a new command type by: + +1. Adding the key to `CHAT_COMMAND_TYPES` in `contract/index.ts`. +2. Adding a handler method in `ChatCommandsService`. +3. Adding a dispatch branch in `executeCommand`. +4. Inserting a seed row in `tools/db/seed.ts`. + +## Invariants + +- Gift send: debit + `chatGift` insert + system message are atomic in one transaction. `messageId` is back-filled in the same transaction after `postSystemMessage` returns. +- Gift claim: the `UPDATE ... WHERE claimed_by IS NULL` is the idempotency guard — no separate lock needed. Self-claim is rejected before the update attempt. +- Rain recipients are capped by `config.maxRecipients` (default 50) and filtered to exclude the actor. +- All money movement is transactional: debit + credits + system message happen atomically. +- `mapConcurrent` (limit 10) is used for rain credits — never `Promise.all` on an unbounded recipient list. +- `adminUpdateCommand` uses an upsert so a missing config row is created on first admin call. diff --git a/packages/core/src/engagement/chat-commands/contract/index.ts b/packages/core/src/engagement/chat-commands/contract/index.ts new file mode 100644 index 00000000..51e7b779 --- /dev/null +++ b/packages/core/src/engagement/chat-commands/contract/index.ts @@ -0,0 +1,170 @@ +import { oc } from '@orpc/contract'; +import * as z from 'zod'; +import { UuidSchema, MoneyAmountSchema, SystemChatMessageSchema } from '@openora/core/contracts'; + +export const CHAT_COMMAND_TYPES = [ + 'mention', + 'profile', + 'gift', + 'rain', + 'donate', + 'block', + 'ignore', +] as const; +export const ChatCommandTypeSchema = z.enum(CHAT_COMMAND_TYPES); +export type ChatCommandType = z.infer; + +export const CommandConfigSchema = z.object({ + maxAmount: MoneyAmountSchema.optional(), + minAmount: MoneyAmountSchema.optional(), + maxRecipients: z.number().int().positive().optional(), +}); +export type CommandConfig = z.infer; + +export const ChatCommandDescriptorSchema = z.object({ + key: ChatCommandTypeSchema, + enabled: z.boolean(), + label: z.string(), + description: z.string().nullable(), + config: CommandConfigSchema.nullable(), +}); +export type ChatCommandDescriptor = z.infer; + +export const MentionResultSchema = z.object({ + userId: UuidSchema, + username: z.string(), +}); +export type MentionResult = z.infer; + +export { SystemChatMessageSchema }; + +export const GiftStateSchema = z.object({ + id: UuidSchema, + senderId: UuidSchema, + senderUsername: z.string(), + amount: MoneyAmountSchema, + currency: z.string(), + claimedBy: UuidSchema.nullable(), + claimedByUsername: z.string().nullable(), + claimedAt: z.string().nullable(), + createdAt: z.string(), +}); +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(), + totalWagered: MoneyAmountSchema, + totalBets: z.number().int(), + currency: z.string(), +}); +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(), + }), + ]), + ) + .output(SystemChatMessageSchema), + + 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), + + mentionSearch: oc + .route({ method: 'GET', path: '/chat-command/mention-search' }) + .input( + z.object({ + q: z.string().min(1).max(50), + limit: z.coerce.number().int().min(1).max(100).default(50), + }), + ) + .output(z.array(MentionResultSchema)), + + playerSearch: oc + .route({ method: 'GET', path: '/chat-command/player-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)), + + playerProfile: oc + .route({ method: 'GET', path: '/chat-command/player-profile/{userId}' }) + .input(z.object({ userId: UuidSchema })) + .output(PlayerProfileCardSchema), + + adminUpdateCommand: oc + .route({ method: 'PATCH', path: '/chat-command/admin/commands/{key}' }) + .input( + z.object({ + key: ChatCommandTypeSchema, + enabled: z.boolean(), + config: CommandConfigSchema.optional(), + }), + ) + .output(ChatCommandDescriptorSchema), +}; diff --git a/packages/core/src/engagement/chat-commands/drizzle.config.ts b/packages/core/src/engagement/chat-commands/drizzle.config.ts new file mode 100644 index 00000000..06155326 --- /dev/null +++ b/packages/core/src/engagement/chat-commands/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_chat_commands', + 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/chat-commands/drizzle/migrations/0000_chemical_grandmaster.sql b/packages/core/src/engagement/chat-commands/drizzle/migrations/0000_chemical_grandmaster.sql new file mode 100644 index 00000000..4774bb7d --- /dev/null +++ b/packages/core/src/engagement/chat-commands/drizzle/migrations/0000_chemical_grandmaster.sql @@ -0,0 +1,8 @@ +CREATE TABLE "chat_command_config" ( + "key" text PRIMARY KEY NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "label" text NOT NULL, + "description" text, + "config" jsonb, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); diff --git a/packages/core/src/engagement/chat-commands/drizzle/migrations/0001_overjoyed_robin_chapel.sql b/packages/core/src/engagement/chat-commands/drizzle/migrations/0001_overjoyed_robin_chapel.sql new file mode 100644 index 00000000..a477e90b --- /dev/null +++ b/packages/core/src/engagement/chat-commands/drizzle/migrations/0001_overjoyed_robin_chapel.sql @@ -0,0 +1,32 @@ +CREATE TABLE "chat_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 NOT NULL, + "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 +/* + Unfortunately in current drizzle-kit version we can't automatically get name for primary key. + We are working on making it available! + + Meanwhile you can: + 1. Check pk name in your database, by running + SELECT constraint_name FROM information_schema.table_constraints + WHERE table_schema = 'public' + AND table_name = 'chat_command_config' + AND constraint_type = 'PRIMARY KEY'; + 2. Uncomment code below and paste pk name manually + + Hope to release this update as soon as possible +*/ + +ALTER TABLE "chat_command_config" DROP CONSTRAINT "chat_command_config_pkey";--> statement-breakpoint +ALTER TABLE "chat_command_config" ADD COLUMN "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL;--> statement-breakpoint +ALTER TABLE "chat_command_config" ADD CONSTRAINT "chat_command_config_key_unique" UNIQUE("key"); diff --git a/packages/core/src/engagement/chat-commands/drizzle/migrations/0002_robust_zombie.sql b/packages/core/src/engagement/chat-commands/drizzle/migrations/0002_robust_zombie.sql new file mode 100644 index 00000000..f4b312b9 --- /dev/null +++ b/packages/core/src/engagement/chat-commands/drizzle/migrations/0002_robust_zombie.sql @@ -0,0 +1,12 @@ +CREATE TYPE "public"."chat_command_type" AS ENUM('mention', 'profile', 'gift', 'rain', 'donate', 'block', 'ignore');--> statement-breakpoint +CREATE TABLE "chat_command_idempotency" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "actor_id" uuid NOT NULL, + "command_type" "chat_command_type" NOT NULL, + "idempotency_key" uuid NOT NULL, + "amount" numeric(18, 8) NOT NULL, + "result" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "chat_command_idempotency_actor_type_key_idx" ON "chat_command_idempotency" USING btree ("actor_id","command_type","idempotency_key"); diff --git a/packages/core/src/engagement/chat-commands/drizzle/migrations/0003_huge_the_leader.sql b/packages/core/src/engagement/chat-commands/drizzle/migrations/0003_huge_the_leader.sql new file mode 100644 index 00000000..78d06597 --- /dev/null +++ b/packages/core/src/engagement/chat-commands/drizzle/migrations/0003_huge_the_leader.sql @@ -0,0 +1 @@ +ALTER TABLE "chat_command_idempotency" ADD COLUMN "fingerprint" text NOT NULL; \ No newline at end of file diff --git a/packages/core/src/engagement/chat-commands/drizzle/migrations/meta/0000_snapshot.json b/packages/core/src/engagement/chat-commands/drizzle/migrations/meta/0000_snapshot.json new file mode 100644 index 00000000..763cde11 --- /dev/null +++ b/packages/core/src/engagement/chat-commands/drizzle/migrations/meta/0000_snapshot.json @@ -0,0 +1,70 @@ +{ + "id": "801a54af-21f0-4268-9970-f23b672527bd", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.chat_command_config": { + "name": "chat_command_config", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/core/src/engagement/chat-commands/drizzle/migrations/meta/0001_snapshot.json b/packages/core/src/engagement/chat-commands/drizzle/migrations/meta/0001_snapshot.json new file mode 100644 index 00000000..3b71ba72 --- /dev/null +++ b/packages/core/src/engagement/chat-commands/drizzle/migrations/meta/0001_snapshot.json @@ -0,0 +1,164 @@ +{ + "id": "461d3079-998d-4743-bd82-78e273431963", + "prevId": "801a54af-21f0-4268-9970-f23b672527bd", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.chat_command_config": { + "name": "chat_command_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_command_config_key_unique": { + "name": "chat_command_config_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_gift": { + "name": "chat_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": true + }, + "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 + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/core/src/engagement/chat-commands/drizzle/migrations/meta/0002_snapshot.json b/packages/core/src/engagement/chat-commands/drizzle/migrations/meta/0002_snapshot.json new file mode 100644 index 00000000..695a37f6 --- /dev/null +++ b/packages/core/src/engagement/chat-commands/drizzle/migrations/meta/0002_snapshot.json @@ -0,0 +1,256 @@ +{ + "id": "186e5f18-65ad-4fba-9279-870da1f4fc83", + "prevId": "461d3079-998d-4743-bd82-78e273431963", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.chat_command_config": { + "name": "chat_command_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_command_config_key_unique": { + "name": "chat_command_config_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_command_idempotency": { + "name": "chat_command_idempotency", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "command_type": { + "name": "command_type", + "type": "chat_command_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(18, 8)", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_command_idempotency_actor_type_key_idx": { + "name": "chat_command_idempotency_actor_type_key_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "command_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_gift": { + "name": "chat_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": true + }, + "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 + } + }, + "enums": { + "public.chat_command_type": { + "name": "chat_command_type", + "schema": "public", + "values": ["mention", "profile", "gift", "rain", "donate", "block", "ignore"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/core/src/engagement/chat-commands/drizzle/migrations/meta/0003_snapshot.json b/packages/core/src/engagement/chat-commands/drizzle/migrations/meta/0003_snapshot.json new file mode 100644 index 00000000..f4bcdd47 --- /dev/null +++ b/packages/core/src/engagement/chat-commands/drizzle/migrations/meta/0003_snapshot.json @@ -0,0 +1,262 @@ +{ + "id": "9731ab0a-199c-4481-906b-b038e7dbfc0c", + "prevId": "186e5f18-65ad-4fba-9279-870da1f4fc83", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.chat_command_config": { + "name": "chat_command_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_command_config_key_unique": { + "name": "chat_command_config_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_command_idempotency": { + "name": "chat_command_idempotency", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "command_type": { + "name": "command_type", + "type": "chat_command_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(18, 8)", + "primaryKey": false, + "notNull": true + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_command_idempotency_actor_type_key_idx": { + "name": "chat_command_idempotency_actor_type_key_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "command_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_gift": { + "name": "chat_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": true + }, + "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 + } + }, + "enums": { + "public.chat_command_type": { + "name": "chat_command_type", + "schema": "public", + "values": ["mention", "profile", "gift", "rain", "donate", "block", "ignore"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/core/src/engagement/chat-commands/drizzle/migrations/meta/_journal.json b/packages/core/src/engagement/chat-commands/drizzle/migrations/meta/_journal.json new file mode 100644 index 00000000..555e2988 --- /dev/null +++ b/packages/core/src/engagement/chat-commands/drizzle/migrations/meta/_journal.json @@ -0,0 +1,34 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1785142634091, + "tag": "0000_chemical_grandmaster", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1785227229512, + "tag": "0001_overjoyed_robin_chapel", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1785486987199, + "tag": "0002_robust_zombie", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1785501886661, + "tag": "0003_huge_the_leader", + "breakpoints": true + } + ] +} diff --git a/packages/core/src/engagement/chat-commands/migrate.ts b/packages/core/src/engagement/chat-commands/migrate.ts new file mode 100644 index 00000000..5a44cf46 --- /dev/null +++ b/packages/core/src/engagement/chat-commands/migrate.ts @@ -0,0 +1,15 @@ +// 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 chat-commands 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_chat_commands', + migrationsSchema: 'drizzle', + ...(databaseUrl ? { databaseUrl } : {}), + }); +} diff --git a/packages/core/src/engagement/chat-commands/plugin.ts b/packages/core/src/engagement/chat-commands/plugin.ts new file mode 100644 index 00000000..523d1ebb --- /dev/null +++ b/packages/core/src/engagement/chat-commands/plugin.ts @@ -0,0 +1,33 @@ +import { definePlugin, DRIZZLE, EVENT_BUS, ADMIN_GUARD } from '@openora/core/server'; +import { + WALLET_COMMANDS, + ADMIN_USER_DIRECTORY, + ADMIN_GAME_REPORTING, + AUDIT_WRITER, + CHAT_REALTIME_TRANSPORT, + CHAT_SYSTEM_WRITER, + CHAT_BLOCK_WRITER, +} from '@openora/core/contracts'; +import { ChatCommandsService } from './service/chat-commands.service.js'; +import { createChatCommandsRouter } from './router/index.js'; + +export default definePlugin({ + id: 'chat-commands', + dependsOn: ['chat', 'wallet', 'iam', 'audit', 'gaming'], + 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), + ); + 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 new file mode 100644 index 00000000..2a7071fd --- /dev/null +++ b/packages/core/src/engagement/chat-commands/router/index.ts @@ -0,0 +1,92 @@ +import { implement } from '@orpc/server'; +import { populateContractRouterPaths } from '@orpc/contract'; +import { mapErrors, getUserId, AdminGuard, type OssContext } from '@openora/core/server'; +import { chatCommandsContract } from '../contract/index.js'; +import { + ChatCommandsService, + CommandDisabledError, + ChatPlayerNotFoundError, + InsufficientBalanceError, + ExceedsLimitError, + BelowMinimumError, + NoOnlineUsersError, + RainCreditError, + GiftNotFoundError, + GiftAlreadyClaimedError, + GiftSelfClaimError, + DonateSelfError, + TooManyRecipientsError, + SelfModerationActionError, + ChatCommandIdempotencyKeyReuseError, + ConcurrentCommandReplayError, +} from '../service/chat-commands.service.js'; + +const cc = populateContractRouterPaths({ chatCommands: chatCommandsContract }).chatCommands; + +export function createChatCommandsRouter(svc: ChatCommandsService, adminGuard: AdminGuard) { + const os = implement(cc).$context(); + + return os.router({ + listCommands: os.listCommands.handler(() => svc.listCommands()), + + execute: os.execute.handler(({ input, context }) => { + const actorId = getUserId(context); + return mapErrors( + { + NOT_FOUND: [CommandDisabledError, ChatPlayerNotFoundError], + CONFLICT: [ + InsufficientBalanceError, + ExceedsLimitError, + BelowMinimumError, + NoOnlineUsersError, + RainCreditError, + DonateSelfError, + TooManyRecipientsError, + SelfModerationActionError, + ChatCommandIdempotencyKeyReuseError, + ConcurrentCommandReplayError, + ], + }, + () => svc.executeCommand(input, actorId), + ); + }), + + getGift: os.getGift.handler(({ input }) => + mapErrors({ NOT_FOUND: [GiftNotFoundError] }, () => svc.getGift(input.id)), + ), + + claimGift: os.claimGift.handler(({ input, context }) => { + const claimerId = getUserId(context); + return mapErrors( + { + NOT_FOUND: [GiftNotFoundError], + CONFLICT: [GiftAlreadyClaimedError, GiftSelfClaimError], + }, + () => 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 }) => { + const viewerId = getUserId(context); + return svc.searchPlayers(input.q, input.limit, viewerId); + }), + + playerProfile: os.playerProfile.handler(({ input, context }) => { + getUserId(context); + return mapErrors({ NOT_FOUND: [ChatPlayerNotFoundError] }, () => + svc.getPlayerProfile(input.userId), + ); + }), + + adminUpdateCommand: os.adminUpdateCommand.handler(async ({ input, context }) => { + await adminGuard.assert(context); + const actorId = getUserId(context); + return svc.adminUpdateCommand(input, actorId); + }), + }); +} diff --git a/packages/core/src/engagement/chat-commands/schema/index.ts b/packages/core/src/engagement/chat-commands/schema/index.ts new file mode 100644 index 00000000..be300a78 --- /dev/null +++ b/packages/core/src/engagement/chat-commands/schema/index.ts @@ -0,0 +1,90 @@ +import { + pgTable, + text, + boolean, + jsonb, + timestamp, + uuid, + unique, + decimal, + pgEnum, + uniqueIndex, +} from 'drizzle-orm/pg-core'; +import type { ChatSystemMessage } from '@openora/core/contracts'; +import type { CommandConfig } from '../contract/index.js'; +import { CHAT_COMMAND_TYPES } from '../contract/index.js'; + +export const chatCommandTypeEnum = pgEnum('chat_command_type', CHAT_COMMAND_TYPES); + +export const chatCommandConfig = pgTable( + 'chat_command_config', + { + id: uuid().primaryKey().defaultRandom(), + key: text().notNull(), + enabled: boolean().notNull().default(true), + label: text().notNull(), + description: text(), + config: jsonb().$type(), + updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow(), + }, + (t) => [unique('chat_command_config_key_unique').on(t.key)], +); + +export type ChatCommandConfig = typeof chatCommandConfig.$inferSelect; + +/** + * Claimable gift card: sender is debited on send, first other player to claim + * wins the credit. No FK to chatMessage — cross-module boundary rule. + */ +export const chatGift = pgTable('chat_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().notNull(), + claimedBy: uuid(), + claimedByUsername: text(), + claimedAt: timestamp({ withTimezone: true }), + createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), +}); + +export type ChatGift = typeof chatGift.$inferSelect; + +/** + * Idempotency guard for money-moving commands (gift/rain/donate). The row IS the + * atomic guard: inserted (with a null `result`) BEFORE the debit inside the same + * transaction, backfilled with the final result after - the same + * insert-placeholder-then-backfill idiom `chatGift.messageId` already uses in this + * module. A concurrent duplicate request loses the unique-index race and must NOT + * touch money; the caller re-reads the winner's row instead (mirrors wallet's + * insertIdempotentTransaction). `result` is jsonb because chat-commands doesn't own + * chatMessage (chat module does) - storing the full ChatSystemMessage here avoids a + * cross-module read on replay. `fingerprint` is a sha256 hash of the FULL command + * input (every field, not just amount) - a replay must match the whole request, not + * just the amount, or a reused key with a different room/recipient/donate target + * would silently return the wrong stored result. + */ +export const chatCommandIdempotency = pgTable( + 'chat_command_idempotency', + { + id: uuid().primaryKey().defaultRandom(), + actorId: uuid().notNull(), + commandType: chatCommandTypeEnum().notNull(), + idempotencyKey: uuid().notNull(), + amount: decimal({ precision: 18, scale: 8 }).notNull(), + fingerprint: text().notNull(), + result: jsonb().$type(), + createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + uniqueIndex('chat_command_idempotency_actor_type_key_idx').on( + t.actorId, + t.commandType, + t.idempotencyKey, + ), + ], +); +export type ChatCommandIdempotency = typeof chatCommandIdempotency.$inferSelect; diff --git a/packages/core/src/engagement/chat-commands/seed/index.ts b/packages/core/src/engagement/chat-commands/seed/index.ts new file mode 100644 index 00000000..5606eb02 --- /dev/null +++ b/packages/core/src/engagement/chat-commands/seed/index.ts @@ -0,0 +1,55 @@ +import type { DrizzleDb } from '@openora/core/server'; +import { chatCommandConfig } from '../schema/index.js'; + +export async function seedChatCommands(db: DrizzleDb): Promise { + await db + .insert(chatCommandConfig) + .values([ + { + key: 'mention', + enabled: true, + label: 'Mention', + description: 'Mention an online user with @username', + }, + { + key: 'profile', + enabled: true, + label: 'Profile', + description: "Look up a player's profile", + }, + { + key: 'gift', + enabled: true, + label: 'Gift', + description: 'Send tokens to another player', + config: { minAmount: '1.00000000' }, + }, + { + key: 'rain', + enabled: true, + label: 'Rain', + description: 'Split tokens among online room members', + config: { minAmount: '1.00000000', maxRecipients: 50 }, + }, + { + key: 'donate', + enabled: true, + label: 'Donate', + description: 'Send a direct tip to a specific player', + config: { minAmount: '1.00000000' }, + }, + { + key: 'block', + enabled: true, + label: 'Block', + description: 'Block a player — their messages will be hidden from you', + }, + { + key: 'ignore', + enabled: true, + label: 'Ignore', + description: 'Ignore a player — their messages will be hidden from you', + }, + ]) + .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 new file mode 100644 index 00000000..f386806e --- /dev/null +++ b/packages/core/src/engagement/chat-commands/service/__tests__/chat-commands.service.test.ts @@ -0,0 +1,1040 @@ +import { describe, it, expect, vi } from 'vitest'; +import { mock, makeDrizzle, makeEventBus } from '../../../../testing/mock.js'; +import type { + ChatSystemMessage, + ChatSystemWriter, + ChatBlockWriter, + WalletCommands, + AdminUserDirectory, + AdminGameReporting, + AuditWritePort, + RealtimeTransport, +} from '@openora/core/contracts'; +import { + ChatCommandsService, + CommandDisabledError, + InsufficientBalanceError, + BelowMinimumError, + NoOnlineUsersError, + ExceedsLimitError, + GiftNotFoundError, + GiftAlreadyClaimedError, + GiftSelfClaimError, + DonateSelfError, + ChatPlayerNotFoundError, + TooManyRecipientsError, + SelfModerationActionError, + ChatCommandIdempotencyKeyReuseError, + fingerprintCommand, +} from '../chat-commands.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) }); +} + +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 makeSvc( + overrides: { + drizzleRows?: { + select?: Record[][]; + returning?: Record[][]; + execute?: Record[][]; + }; + writer?: ChatSystemWriter; + wallet?: WalletCommands; + directory?: AdminUserDirectory; + transport?: RealtimeTransport; + audit?: AuditWritePort; + gameReporting?: AdminGameReporting; + blockWriter?: ChatBlockWriter; + } = {}, +) { + 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(), + ); +} + +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 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 result = await svc.listCommands(true); + expect(result).toHaveLength(2); + }); +}); + +describe('ChatCommandsService.searchMentions', () => { + it('passes limit to findPlayerIds', async () => { + const directory = makeDirectory(); + const svc = makeSvc({ directory }); + await svc.searchMentions('ali', 5, CLAIMER_ID); + expect(directory.findPlayerIds).toHaveBeenCalledWith('ali', 5); + }); + + it('returns empty array when no ids found', async () => { + const directory = mock({ + findPlayerIds: vi.fn().mockResolvedValue([]), + lookupPlayers: vi.fn().mockResolvedValue([]), + }); + const svc = makeSvc({ directory }); + const result = await svc.searchMentions('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.searchMentions('bo', 5, CLAIMER_ID); + expect(blockWriter.getExcludedUserIds).toHaveBeenCalledWith(CLAIMER_ID); + expect(result).toEqual([]); + }); +}); + +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 }]); + }); + + 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); + 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', + }); + }); + + 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)).rejects.toThrow(ChatPlayerNotFoundError); + }); +}); + +const IDEMPOTENCY_KEY = '00000000-0000-0000-0000-0000000000aa'; +const IDEMPOTENCY_ROW_ID = '00000000-0000-0000-0000-0000000000bb'; + +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 }, + 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), + }); + await expect( + svc.executeCommand( + { type: 'gift', amount: '10', 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: [[{ id: IDEMPOTENCY_ROW_ID }], [{ ...GIFT_ROW }]], + }, + writer, + }); + 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); + }); + + 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, + }, + ACTOR_ID, + ), + ).rejects.toThrow(BelowMinimumError); + }); +}); + +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], [storedRow]] }, + 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], [storedRow]] }, + }); + 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], [storedRow]] }, + }); + await expect( + svc.executeCommand( + { type: 'gift', amount: '10.00000000', roomId: ROOM_ID, idempotencyKey: IDEMPOTENCY_KEY }, + ACTOR_ID, + ), + ).rejects.toThrow(ChatCommandIdempotencyKeyReuseError); + }); +}); + +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(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: [], + }, + }); + await expect(svc.claimGift(GIFT_ID, ACTOR_ID)).rejects.toThrow(GiftSelfClaimError); + }); + + 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); + }); + + it('throws GiftNotFoundError when gift does not exist', async () => { + const svc = makeSvc({ + drizzleRows: { + select: [[]], // no gift row + returning: [], + }, + }); + await expect(svc.claimGift(GIFT_ID, CLAIMER_ID)).rejects.toThrow(GiftNotFoundError); + }); +}); + +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]] }, + }); + const result = await svc.adminUpdateCommand({ key: 'gift', enabled: false }, ACTOR_ID); + expect(result.key).toBe('gift'); + expect(result.enabled).toBe(false); + }); + + 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(), + ); + 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', + }), + ); + }); +}); + +describe('ChatCommandsService.handleRain', () => { + const RAIN_ROW = { ...ENABLED_ROW, key: 'rain', label: 'Rain' }; + + it('throws NoOnlineUsersError when no other users are online', async () => { + const svc = makeSvc({ + drizzleRows: { select: [[RAIN_ROW]] }, + transport: makeTransport([ACTOR_ID]), + }); + await expect( + svc.executeCommand( + { + type: 'rain', + amount: '10.00000000', + recipientCount: 5, + roomId: ROOM_ID, + idempotencyKey: IDEMPOTENCY_KEY, + }, + ACTOR_ID, + ), + ).rejects.toThrow(NoOnlineUsersError); + }); + + it('distributes to online recipients excluding the actor', 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( + { + type: 'rain', + amount: '10.00000000', + recipientCount: 2, + roomId: ROOM_ID, + idempotencyKey: IDEMPOTENCY_KEY, + }, + ACTOR_ID, + ); + expect(wallet.credit).toHaveBeenCalledTimes(2); + expect(wallet.credit).toHaveBeenCalledWith(expect.anything(), { + userId: CLAIMER_ID, + amount: '5.00000000', + 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( + { + 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', + 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, + roomId: ROOM_ID, + idempotencyKey: IDEMPOTENCY_KEY, + }, + ACTOR_ID, + ); + expect(wallet.debit).toHaveBeenCalledWith(expect.anything(), { + userId: ACTOR_ID, + amount: '10.00000000', + type: 'rain', + }); + }); + + it('throws BelowMinimumError when amount is below config minAmount', async () => { + const svc = makeSvc({ + drizzleRows: { + select: [[{ ...RAIN_ROW, config: { minAmount: '5.00000000' } }]], + }, + }); + await expect( + svc.executeCommand( + { + type: 'rain', + amount: '1.00000000', + recipientCount: 1, + roomId: ROOM_ID, + idempotencyKey: IDEMPOTENCY_KEY, + }, + ACTOR_ID, + ), + ).rejects.toThrow(BelowMinimumError); + }); + + 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( + { + type: 'rain', + amount: '50.00000000', + recipientCount: 1, + roomId: ROOM_ID, + idempotencyKey: IDEMPOTENCY_KEY, + }, + ACTOR_ID, + ), + ).rejects.toThrow(ExceedsLimitError); + }); + + it('throws ExceedsLimitError when recipientCount exceeds config maxRecipients', async () => { + const svc = makeSvc({ + drizzleRows: { + select: [[{ ...RAIN_ROW, config: { maxRecipients: 10 } }]], + }, + }); + await expect( + svc.executeCommand( + { + type: 'rain', + amount: '100.00000000', + recipientCount: 11, + roomId: ROOM_ID, + idempotencyKey: IDEMPOTENCY_KEY, + }, + ACTOR_ID, + ), + ).rejects.toThrow(ExceedsLimitError); + }); + + it('throws TooManyRecipientsError when recipientCount exceeds the whole-dollar amount', async () => { + const svc = makeSvc({ + drizzleRows: { select: [[RAIN_ROW]] }, + }); + await expect( + svc.executeCommand( + { + type: 'rain', + amount: '3.00000000', + recipientCount: 4, + 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, + ); + }), + }); +} + +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), + }); + 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', + type: 'tip', + }); + expect(result.id).toBe(MSG_ID); + }); + + 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( + { + type: 'donate', + targetUsername: 'bob', + amount: '10.00000000', + roomId: ROOM_ID, + idempotencyKey: IDEMPOTENCY_KEY, + }, + ACTOR_ID, + ), + ).rejects.toThrow(DonateSelfError); + }); + + 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), + }); + const svc = makeSvc({ + drizzleRows: { select: [[DONATE_ROW]] }, + directory, + }); + 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(), + }); + 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); + }); +}); + +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, + }); + + await svc.executeCommand({ type: 'block', targetUsername: 'alice', roomId: ROOM_ID }, ACTOR_ID); + + expect(blockWriter.blockUser).toHaveBeenCalledWith(ACTOR_ID, CLAIMER_ID); + expect(blockWriter.ignoreUser).not.toHaveBeenCalled(); + }); + + it('dispatches "ignore" to blockWriter.ignoreUser, not blockUser', async () => { + const blockWriter = makeBlockWriter(); + const svc = makeSvc({ + drizzleRows: { select: [[IGNORE_ROW]] }, + directory: makeRecipientDirectory(), + blockWriter, + }); + + await svc.executeCommand( + { type: 'ignore', targetUsername: 'alice', roomId: ROOM_ID }, + ACTOR_ID, + ); + + expect(blockWriter.ignoreUser).toHaveBeenCalledWith(ACTOR_ID, CLAIMER_ID); + expect(blockWriter.blockUser).not.toHaveBeenCalled(); + }); + + it('throws SelfModerationActionError on self-block', async () => { + const svc = makeSvc({ + drizzleRows: { select: [[BLOCK_ROW]] }, + }); + + await expect( + svc.executeCommand({ type: 'block', targetUsername: 'bob', roomId: ROOM_ID }, ACTOR_ID), + ).rejects.toThrow(SelfModerationActionError); + }); + + it('throws SelfModerationActionError on self-ignore', async () => { + const svc = makeSvc({ + drizzleRows: { select: [[IGNORE_ROW]] }, + }); + + await expect( + svc.executeCommand({ type: 'ignore', targetUsername: 'bob', roomId: ROOM_ID }, ACTOR_ID), + ).rejects.toThrow(SelfModerationActionError); + }); +}); 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 new file mode 100644 index 00000000..bc436f58 --- /dev/null +++ b/packages/core/src/engagement/chat-commands/service/chat-commands.service.ts @@ -0,0 +1,853 @@ +import { createHash } from 'node:crypto'; +import { and, eq, isNull, sql } from 'drizzle-orm'; +import { + DrizzleService, + makeNotFoundError, + makeConflictError, + mapConcurrent, + moneyToNumber, + findOneOrThrow, + serializeRow, + type EventBus, + type DrizzleTx, +} from '@openora/core/server'; +import type { + Uuid, + ChatSystemMessage, + ChatSystemWriter, + ChatBlockWriter, + WalletCommands, + AdminUserDirectory, + AdminGameReporting, + AuditWritePort, + RealtimeTransport, +} from '@openora/core/contracts'; +import { chatChannel } from '@openora/core/contracts'; +import type { + ChatCommandDescriptor, + ChatCommandType, + CommandConfig, + GiftState, + ClaimGiftOutput, + PlayerSearchResult, + PlayerProfileCard, +} from '../contract/index.js'; +import { ChatCommandTypeSchema } from '../contract/index.js'; +import { chatCommandConfig, chatGift, chatCommandIdempotency } 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 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 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', +); + +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; + +// 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), + enabled: row.enabled, + label: row.label, + description: row.description ?? null, + config: row.config ?? null, + }; +} + +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, + ) {} + + async listCommands(includeDisabled = false): Promise { + const rows = await this.drizzle.db + .select() + .from(chatCommandConfig) + .where(includeDisabled ? undefined : eq(chatCommandConfig.enabled, true)); + 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): Promise { + const summaries = await this.directory.lookupPlayers([userId]); + const summary = summaries.find((s) => s.userId === userId); + if (!summary) { + throw new ChatPlayerNotFoundError(userId); + } + const stats = await this.gameReporting.getPlayerStats(userId); + return { + userId: summary.userId, + username: summary.username, + avatarUrl: summary.avatarUrl, + level: summary.level, + joinedAt: summary.createdAt.toISOString(), + totalWagered: stats.totalWagered, + totalBets: stats.totalBets, + currency: summary.currency, + }; + } + + async executeCommand(input: ExecuteInput, actorId: Uuid): Promise { + 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); + } + + // Pre-transaction replay check shared by gift/rain/donate: a stored row with a + // matching fingerprint (hash of the full request, not just the amount) is a genuine + // replay (return its stored result); a matching key with a DIFFERENT fingerprint is a + // reused key, not a replay (ChatCommandIdempotencyKeyReuseError). + private async findCommandReplay( + commandType: 'gift' | 'rain' | 'donate', + actorId: Uuid, + idempotencyKey: Uuid, + fingerprint: string, + ): Promise { + const [row] = await this.drizzle.db + .select() + .from(chatCommandIdempotency) + .where( + and( + eq(chatCommandIdempotency.actorId, actorId), + eq(chatCommandIdempotency.commandType, commandType), + eq(chatCommandIdempotency.idempotencyKey, idempotencyKey), + ), + ) + .limit(1); + if (!row) { + return null; + } + if (row.fingerprint !== fingerprint) { + throw new ChatCommandIdempotencyKeyReuseError(); + } + return row.result ?? null; + } + + // Inserted (result: null) as the FIRST statement inside the caller's money-moving + // transaction, before any debit - the row IS the atomic guard. A concurrent duplicate + // loses the unique-index race (onConflictDoNothing) and must not touch money, so it + // throws ConcurrentCommandReplayError rather than silently proceeding; the caller's + // own retry then finds the row via findCommandReplay instead. + private async guardCommandIdempotency( + tx: DrizzleTx, + commandType: 'gift' | 'rain' | 'donate', + actorId: Uuid, + idempotencyKey: Uuid, + amount: string, + fingerprint: string, + ): Promise { + const [inserted] = await tx + .insert(chatCommandIdempotency) + .values({ actorId, commandType, idempotencyKey, amount, fingerprint, result: null }) + .onConflictDoNothing({ + target: [ + chatCommandIdempotency.actorId, + chatCommandIdempotency.commandType, + chatCommandIdempotency.idempotencyKey, + ], + }) + .returning(); + if (!inserted) { + throw new ConcurrentCommandReplayError(); + } + return inserted.id; + } + + // 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; + } + + // 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.drizzle.db.transaction(async (tx) => { + const idempotencyRowId = await this.guardCommandIdempotency( + tx, + 'gift', + actorId, + input.idempotencyKey, + input.amount, + fingerprint, + ); + + 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 tx + .update(chatCommandIdempotency) + .set({ result: systemMsg }) + .where(eq(chatCommandIdempotency.id, idempotencyRowId)); + + return { msg: systemMsg, giftId: giftRow.id, currency: debit.currency }; + }); + + // 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); + + await this.audit.record({ + actorId, + actorType: 'player', + action: 'chat.gift', + resourceType: 'chat_gift', + resourceId: giftId, + before: null, + after: { amount: input.amount, roomId: input.roomId }, + }); + + 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): Promise { + const rows = await this.drizzle.db + .select() + .from(chatGift) + .where(eq(chatGift.id, giftId)) + .limit(1); + const row = findOneOrThrow(rows, new GiftNotFoundError(giftId)); + 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)); + + 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, + type: 'gift', + }); + if (!credit.ok) { + throw new GiftNotFoundError(claimerId); + } + + return { claimed: updated, currency: updated.currency, roomId: updated.roomId }; + }); + + await this.audit.record({ + actorId: claimerId, + actorType: 'player', + action: 'chat.gift.claimed', + resourceType: 'chat_gift', + resourceId: giftId, + before: null, + after: { claimedBy: claimerId, amount: claimed.amount }, + }); + + 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 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 fingerprint = fingerprintCommand(input); + const replay = await this.findCommandReplay('rain', actorId, input.idempotencyKey, fingerprint); + if (replay) { + return replay; + } + + const { msg, currency, totalDistributed } = await this.drizzle.db.transaction(async (tx) => { + const idempotencyRowId = await this.guardCommandIdempotency( + tx, + 'rain', + actorId, + input.idempotencyKey, + input.amount, + fingerprint, + ); + + const splitResult = await tx.execute( + sql`SELECT + (floor(${input.amount}::numeric / ${recipients.length}))::text AS per_recipient, + (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, 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 tx + .update(chatCommandIdempotency) + .set({ result: systemMsg }) + .where(eq(chatCommandIdempotency.id, idempotencyRowId)); + + return { msg: systemMsg, currency: debit.currency, totalDistributed }; + }); + + void this.transport.publish(chatChannel(input.roomId), msg); + + await this.audit.record({ + actorId, + actorType: 'player', + action: 'chat.rain', + resourceType: 'chat_room', + resourceId: input.roomId, + before: null, + after: { amount: totalDistributed, recipientCount: recipients.length }, + }); + 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; + } + + const { msg, currency } = await this.drizzle.db.transaction(async (tx) => { + const idempotencyRowId = await this.guardCommandIdempotency( + tx, + 'donate', + actorId, + input.idempotencyKey, + input.amount, + fingerprint, + ); + + 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, + 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 tx + .update(chatCommandIdempotency) + .set({ result: systemMsg }) + .where(eq(chatCommandIdempotency.id, idempotencyRowId)); + + return { msg: systemMsg, currency: debit.currency }; + }); + + void this.transport.publish(chatChannel(input.roomId), msg); + + await this.audit.record({ + actorId, + actorType: 'player', + action: 'chat.donate', + resourceType: 'chat_donation', + resourceId: msg.id, + before: null, + after: { recipientId: target.userId, amount: input.amount, currency }, + }); + + 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 adminUpdateCommand( + input: { key: ChatCommandType; enabled: boolean; config?: CommandConfig }, + actorId: Uuid, + ): Promise { + const rows = await this.drizzle.db + .insert(chatCommandConfig) + .values({ + key: input.key, + enabled: input.enabled, + label: input.key.charAt(0).toUpperCase() + input.key.slice(1), + config: input.config ?? null, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: chatCommandConfig.key, + set: { + enabled: input.enabled, + ...(input.config !== undefined ? { config: input.config } : {}), + updatedAt: new Date(), + }, + }) + .returning(); + const row = findOneOrThrow(rows, new CommandDisabledError(input.key)); + await this.audit.record({ + actorId, + actorType: 'admin', + action: 'chat.command.updated', + resourceType: 'chat_command', + resourceId: input.key, + before: null, + after: { enabled: input.enabled, config: input.config ?? null }, + }); + return toDescriptor(row); + } +} diff --git a/packages/core/src/engagement/chat/AGENTS.md b/packages/core/src/engagement/chat/AGENTS.md index fa5abbb7..9c9e8d72 100644 --- a/packages/core/src/engagement/chat/AGENTS.md +++ b/packages/core/src/engagement/chat/AGENTS.md @@ -8,12 +8,16 @@ Realtime: by default, the bindings are `InProcessRealtimeTransport` and `SseClie Presence and FE adoption: call `getOnlineCount({ roomId })` to display the current unique online count (`roomId: null` is global chat). Opening the SSE `streamMessages` route enters presence and closing it leaves; authenticated tabs are de-duplicated per user, while anonymous global viewers count separately. A managed realtime adapter must enter and leave presence on the same `chat:global` / `chat:room:{id}` channel for each connection and refresh grants after a room membership change. -Routes (private-room lifecycle): `createPrivateRoom` (a player creates up to 15 active `private-channels` rooms, is auto-joined as moderator, and receives a join code), `joinRoom` (join by code; 404 if invalid, 403 if banned), `leaveRoom` (idempotent), `getRoom` (room detail; joinCode populated for members of private rooms), `kickMember` (moderator removes member - can rejoin), `banMember` (moderator bans member - cannot rejoin even with code; idempotent), and `listRoomMembers` (members only for private rooms). +Routes (private-room lifecycle): `createPrivateRoom` (a player creates up to 15 active `private-channels` rooms, is auto-joined as moderator, and receives a join code), `joinRoom` (join by code; 404 if invalid, 403 if banned), `leaveRoom` (idempotent), `getRoom` (room detail; joinCode populated for members of private rooms), `kickMember` (moderator removes member - can rejoin), `banMember` (moderator bans member - cannot rejoin even with code; idempotent), and `listRoomMembers` (members only for private rooms; each row carries a server-resolved `username` via `ADMIN_USER_DIRECTORY.lookupPlayers`, null when the lookup can't resolve an id - fail-open, not an error. The client no longer derives a display name by scanning loaded chat history, which broke for a member who joined but never posted, or whose last message aged out of the loaded window). + +Ports consumed: `chat` depends on the `identity` module (`dependsOn: ['identity']`) for `ADMIN_USER_DIRECTORY`, used only to enrich `listRoomMembers` with usernames - no schema import, no other identity dependency. Routes (admin-only, AdminGuard-enforced): `createRoom` (POST /backoffice/chat/rooms - creates a public room by slug and required category), `listAdminRooms` (GET /backoffice/chat/rooms - paginated public rooms, sortable by name or creation time), `updateRoom` (PATCH /backoffice/chat/rooms/{id} - changes name, slug, and/or category), and `deleteRoom` (DELETE /backoffice/chat/rooms/{id} - soft-deletes the room while preserving messages, memberships, and bans). Per-viewer block filtering: message list filters out senders the viewer has blocked; the blocked player is unaffected and can still send. Filters apply to both room and global streams. Username resolves from the verified user row (falls back to header, then `anonymous`); userId resolves from auth. Realtime push uses `REALTIME_TRANSPORT`. +`postSystemMessage` only auto-publishes to `REALTIME_TRANSPORT` when it is called WITHOUT a `tx` (it owns the write itself). A caller that passes its own `tx` - eg `chat-commands`' `handleGift`/`handleRain`/`handleDonate`, which post the system message inside their own money-moving transaction - owns the commit boundary instead, and must publish itself after that transaction resolves successfully. Auto-publishing on a caller-supplied `tx` would leak the message to clients before (or even if) the caller's transaction actually commits. + Access model: public rooms are open to all; private rooms require membership. Deleted rooms are excluded from all room reads, lists, joins, streams, and moderation operations. `verifyRoomAccess(roomId, viewerId?)` is the single authority - called by `getRoomMessages`, `sendRoomMessage`, `getRoom`, `listRoomMembers`, `leaveRoom`, `kickMember`, `banMember`, and, via router, `streamMessages`. Moderator checks in `kickMember`/`banMember` require `role = 'moderator'` in `chatRoomMember`. Audit events: `chat.private_room.created`, `chat.room.member.kicked`, and `chat.room.member.banned` are subscribed by the audit module. `chat.user.blocked`/`chat.user.unblocked` are also audited. `chat.message.sent` is not audited because it is high-volume. diff --git a/packages/core/src/engagement/chat/__tests__/chat.service.test.ts b/packages/core/src/engagement/chat/__tests__/chat.service.test.ts index a7d4469a..f4882fde 100644 --- a/packages/core/src/engagement/chat/__tests__/chat.service.test.ts +++ b/packages/core/src/engagement/chat/__tests__/chat.service.test.ts @@ -4,25 +4,28 @@ import { eq, sql } from 'drizzle-orm'; import { createTestDb, InProcessRealtimeTransport, type TestDb } from '@openora/core/testing'; import { user } from '@openora/core/pam/schema/identity'; import { migrate as migrateIdentity } from '@openora/core/pam/migrate/identity'; -import { NO_CLIENT_META, makeEventBus } from '../../../testing/mock.js'; +import type { AdminUserDirectory, AdminPlayerSummary } from '@openora/core/contracts'; +import { NO_CLIENT_META, makeEventBus, mock } from '../../../testing/mock.js'; import { CHAT_ROOM_CATEGORIES, type ChatMessage } from '../contract/index.js'; import { MAX_PRIVATE_ROOMS_PER_PLAYER } from '../contract/constants.js'; import { migrate } from '../migrate.js'; -import { - chatRoom, - chatMessage, - chatUserBlock, - chatRoomMember, - chatRoomBan, -} from '../schema/index.js'; +// import { +// mock, +// mockDb, +// makeDrizzle, +// makeEvents, +// readPrivate, +// NO_CLIENT_META, +// } from '../../../testing/mock.js'; +import { chatChannel } from '@openora/core/contracts'; import { ChatService, - chatChannel, ChatRoomNotFoundError, ChatMessageNotFoundError, ChatMessageOwnershipError, ChatMessageBlockedError, ChatSelfBlockError, + ChatSelfIgnoreError, ChatRoomSlugConflictError, ChatRoomJoinCodeNotFoundError, ChatRoomBannedError, @@ -32,13 +35,22 @@ import { ChatRoomNotModeratorError, ChatRoomSelfModerationError, } from '../service/chat.service.js'; - +import { + chatMessage, + chatRoom, + chatRoomMember, + chatRoomBan, + chatUserBlock, + chatUserIgnore, +} from '../schema/index.js'; let db: TestDb; -function makeService() { +function makeService( + directory: AdminUserDirectory = mock({ lookupPlayers: async () => [] }), +) { const transport = new InProcessRealtimeTransport(); const events = makeEventBus(); - return { svc: new ChatService(db.drizzle, events, transport), events, transport }; + return { svc: new ChatService(db.drizzle, events, transport, directory), events, transport }; } async function seedUser(name = 'Player') { @@ -96,7 +108,7 @@ afterAll(async () => { beforeEach(async () => { await db.drizzle.db.execute( - sql`TRUNCATE ${chatMessage}, ${chatRoomMember}, ${chatRoomBan}, ${chatUserBlock}, ${chatRoom}, ${user} RESTART IDENTITY CASCADE`, + sql`TRUNCATE ${chatMessage}, ${chatRoomMember}, ${chatRoomBan}, ${chatUserBlock}, ${chatUserIgnore}, ${chatRoom}, ${user} RESTART IDENTITY CASCADE`, ); }); @@ -118,6 +130,8 @@ describe('ChatService realtime wiring', () => { userId: 'u1', username: 'alice', content: 'hi', + type: 'user', + metadata: null, isDeleted: false, createdAt: '2026-05-29T00:00:00.000Z', }; @@ -163,6 +177,8 @@ describe('ChatService.subscribeMessages per-viewer block filtering (real PG)', ( userId: 'sender', username: 'alice', content: 'hi', + type: 'user', + metadata: null, isDeleted: false, createdAt: '2026-05-29T00:00:00.000Z', }; @@ -209,6 +225,22 @@ describe('ChatService.subscribeMessages per-viewer block filtering (real PG)', ( expect(got).toHaveLength(1); }); + + it('hides messages from an ignored (not blocked) sender for the viewer', async () => { + const { svc, transport } = makeService(); + const viewerId = randomUUID(); + const ignoredId = randomUUID(); + await svc.ignoreUser(viewerId, ignoredId); + + const got: ChatMessage[] = []; + svc.subscribeMessages(null, (m) => got.push(m), viewerId); + transport.publish(chatChannel(null), { ...sample, userId: 'other-user' }); + await waitFor(() => got.length === 1); + + transport.publish(chatChannel(null), { ...sample, userId: ignoredId }); + + expect(got.map((m) => m.userId)).toEqual(['other-user']); + }); }); describe('ChatService.sendGlobalMessage (real PG)', () => { @@ -295,6 +327,19 @@ describe('ChatService message reads (real PG)', () => { expect(messages.map((m) => m.id)).toEqual([visible.id]); }); + it('filters out senders the viewer has ignored (not blocked)', async () => { + const { svc } = makeService(); + const viewerId = randomUUID(); + const ignoredId = randomUUID(); + await svc.ignoreUser(viewerId, ignoredId); + await seedMessage({ userId: ignoredId }); + const visible = await seedMessage(); + + const messages = await svc.getGlobalMessages(50, viewerId); + + expect(messages.map((m) => m.id)).toEqual([visible.id]); + }); + it('scopes room messages to the room and honours the before cursor', async () => { const { svc } = makeService(); const room = await seedRoom(); @@ -312,6 +357,20 @@ describe('ChatService message reads (real PG)', () => { expect(messages.map((m) => m.id)).toEqual([old.id]); }); + + it('filters out room senders the viewer has ignored (not blocked)', async () => { + const { svc } = makeService(); + const viewerId = randomUUID(); + const ignoredId = randomUUID(); + const room = await seedRoom(); + await svc.ignoreUser(viewerId, ignoredId); + await seedMessage({ roomId: room.id, userId: ignoredId }); + const visible = await seedMessage({ roomId: room.id }); + + const messages = await svc.getRoomMessages({ roomId: room.id, viewerId }); + + expect(messages.map((m) => m.id)).toEqual([visible.id]); + }); }); describe('ChatService.sendRoomMessage (real PG)', () => { @@ -434,6 +493,71 @@ describe('ChatService block list (real PG)', () => { }); }); +describe('ChatService ignore list (real PG)', () => { + it('rejects ignoring yourself', async () => { + const { svc } = makeService(); + const userId = randomUUID(); + + await expect(svc.ignoreUser(userId, userId)).rejects.toThrow(ChatSelfIgnoreError); + }); + + it('emits only on the first ignore of a pair', async () => { + const { svc, events } = makeService(); + const ignorerId = randomUUID(); + const ignoredId = randomUUID(); + + await svc.ignoreUser(ignorerId, ignoredId, NO_CLIENT_META); + await svc.ignoreUser(ignorerId, ignoredId, NO_CLIENT_META); + + expect(await db.drizzle.db.select().from(chatUserIgnore)).toHaveLength(1); + expect(events.emit.mock.calls.filter(([topic]) => topic === 'chat.user.ignored')).toHaveLength( + 1, + ); + }); + + it('emits on unignore only when a row was actually removed', async () => { + const { svc, events } = makeService(); + const ignorerId = randomUUID(); + const ignoredId = randomUUID(); + await svc.ignoreUser(ignorerId, ignoredId); + + 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); + expect( + events.emit.mock.calls.filter(([topic]) => topic === 'chat.user.unignored'), + ).toHaveLength(1); + }); + + it('lists the ignored ids newest-first', async () => { + const { svc } = makeService(); + const ignorerId = randomUUID(); + const first = randomUUID(); + const second = 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') }, + ]); + + const rows = await svc.listIgnoredUsers(ignorerId); + + expect(rows.map((r) => r.ignoredId)).toEqual([second, first]); + }); + + it('a block and an ignore are independent relationships (blocking does not ignore, and vice versa)', async () => { + const { svc } = makeService(); + const viewerId = randomUUID(); + const blockedId = randomUUID(); + const ignoredId = randomUUID(); + 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]); + }); +}); + describe('ChatService admin rooms (real PG)', () => { it('creates a room and returns the serialized row', async () => { const { svc, events } = makeService(); @@ -848,20 +972,60 @@ describe('ChatService.verifyRoomAccess and listings (real PG)', () => { expect(viewer.map((r) => r.id).sort()).toEqual([publicRoom.id, mine.id].sort()); }); - it('lists room members oldest-first', async () => { - const { svc } = makeService(); + it('lists room members oldest-first, resolving usernames via the directory', async () => { const moderatorId = randomUUID(); + const memberId = randomUUID(); + const now = new Date(); + function summary(userId: string, username: string): AdminPlayerSummary { + return { + userId, + username, + email: `${username}@example.test`, + kycStatus: null, + language: null, + avatarUrl: null, + createdAt: now, + level: 1, + currency: 'USD', + }; + } + const directory = mock({ + lookupPlayers: async (ids: readonly string[]) => + [summary(moderatorId, 'Moderator'), summary(memberId, 'SilentMember')].filter((s) => + ids.includes(s.userId), + ), + }); + const { svc } = makeService(directory); const room = await svc.createPrivateRoom({ userId: moderatorId, name: 'Room', ...NO_CLIENT_META, }); - const memberId = randomUUID(); + // memberId joins via invite code and never sends a message - the exact repro: + // a never-posted member must still resolve a real username, not their raw id. await svc.joinRoom({ userId: memberId, joinCode: room.joinCode!, ...NO_CLIENT_META }); const members = await svc.listRoomMembers({ roomId: room.id, viewerId: moderatorId }); - expect(members.map((m) => m.userId)).toEqual([moderatorId, memberId]); + expect(members.map((m) => ({ userId: m.userId, username: m.username }))).toEqual([ + { userId: moderatorId, username: 'Moderator' }, + { userId: memberId, username: 'SilentMember' }, + ]); expect(members[0]).toMatchObject({ role: 'moderator' }); }); + + it('returns username: null when the directory does not resolve a member', async () => { + const directory = mock({ lookupPlayers: async () => [] }); + const { svc } = makeService(directory); + const moderatorId = randomUUID(); + const room = await svc.createPrivateRoom({ + userId: moderatorId, + name: 'Room', + ...NO_CLIENT_META, + }); + + const members = await svc.listRoomMembers({ roomId: room.id, viewerId: moderatorId }); + + expect(members).toEqual([expect.objectContaining({ userId: moderatorId, username: null })]); + }); }); diff --git a/packages/core/src/engagement/chat/contract/index.ts b/packages/core/src/engagement/chat/contract/index.ts index f9390ad3..ff0587ab 100644 --- a/packages/core/src/engagement/chat/contract/index.ts +++ b/packages/core/src/engagement/chat/contract/index.ts @@ -1,6 +1,12 @@ import { oc, eventIterator } from '@orpc/contract'; import * as z from 'zod'; -import { IdInputSchema, TimestampSchema, UuidSchema } from '@openora/core/contracts'; +import { + IdInputSchema, + TimestampSchema, + UuidSchema, + ChatMessageTypeSchema, + CommandMetadataSchema, +} from '@openora/core/contracts'; import { PageQuerySchema, SortOrderSchema, paginated } from '@openora/core/contracts/kit'; import { MAX_MESSAGE_LENGTH, @@ -57,6 +63,7 @@ export const ChatRoomMemberSchema = z.object({ userId: UuidSchema, role: ChatRoomRoleSchema, joinedAt: TimestampSchema, + username: z.string().nullable(), }); export type ChatRoomMember = z.infer; @@ -68,6 +75,8 @@ export const ChatMessageSchema = z.object({ // UNTRUSTED user text: profanity-gated and URL-defanged server-side but NOT HTML-escaped. // Consumers MUST render as text or escape before injecting into HTML. content: z.string(), + type: ChatMessageTypeSchema, + metadata: CommandMetadataSchema.nullable(), isDeleted: z.boolean(), createdAt: TimestampSchema, }); @@ -78,6 +87,11 @@ export const BlockedUserSchema = z.object({ createdAt: TimestampSchema, }); +export const IgnoredUserSchema = z.object({ + 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. @@ -155,11 +169,30 @@ export const chatContract = { .input(z.object({ blockedId: UuidSchema })) .output(z.object({ success: z.literal(true) })), + listIgnoredUsers: oc + .route({ method: 'GET', path: '/chat/ignores' }) + .output(z.array(IgnoredUserSchema)), + + ignoreUser: oc + .route({ method: 'POST', path: '/chat/ignores' }) + .input(z.object({ ignoredId: UuidSchema })) + .output(z.object({ success: z.literal(true) })), + + unignoreUser: oc + .route({ method: 'DELETE', path: '/chat/ignores/{ignoredId}' }) + .input(z.object({ ignoredId: UuidSchema })) + .output(z.object({ success: z.literal(true) })), + createPrivateRoom: oc .route({ method: 'POST', path: '/chat/rooms/private' }) .input(z.object({ name: z.string().trim().min(1).max(ROOM_NAME_MAX_LENGTH) })) .output(ChatRoomSchema), + deletePrivateRoom: oc + .route({ method: 'DELETE', path: '/chat/rooms/{roomId}' }) + .input(RoomIdInput) + .output(z.object({ success: z.literal(true) })), + joinRoom: oc .route({ method: 'POST', path: '/chat/rooms/join' }) .input(z.object({ joinCode: ChatJoinCodeSchema })) diff --git a/packages/core/src/engagement/chat/drizzle/migrations/0002_steep_zodiak.sql b/packages/core/src/engagement/chat/drizzle/migrations/0002_steep_zodiak.sql new file mode 100644 index 00000000..bd1f5a1c --- /dev/null +++ b/packages/core/src/engagement/chat/drizzle/migrations/0002_steep_zodiak.sql @@ -0,0 +1,3 @@ +CREATE TYPE "public"."chat_message_type" AS ENUM('user', 'system');--> statement-breakpoint +ALTER TABLE "chat_message" ADD COLUMN "type" "chat_message_type" DEFAULT 'user' NOT NULL;--> statement-breakpoint +ALTER TABLE "chat_message" ADD COLUMN "metadata" jsonb; diff --git a/packages/core/src/engagement/chat/drizzle/migrations/0003_stormy_king_bedlam.sql b/packages/core/src/engagement/chat/drizzle/migrations/0003_stormy_king_bedlam.sql new file mode 100644 index 00000000..720740ed --- /dev/null +++ b/packages/core/src/engagement/chat/drizzle/migrations/0003_stormy_king_bedlam.sql @@ -0,0 +1,9 @@ +CREATE TABLE "chat_user_ignore" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "ignorer_id" uuid NOT NULL, + "ignored_id" uuid NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "chat_user_ignore_pair_key" ON "chat_user_ignore" USING btree ("ignorer_id","ignored_id");--> statement-breakpoint +CREATE INDEX "chat_user_ignore_ignorer_idx" ON "chat_user_ignore" USING btree ("ignorer_id"); diff --git a/packages/core/src/engagement/chat/drizzle/migrations/meta/0002_snapshot.json b/packages/core/src/engagement/chat/drizzle/migrations/meta/0002_snapshot.json new file mode 100644 index 00000000..cb9c4f28 --- /dev/null +++ b/packages/core/src/engagement/chat/drizzle/migrations/meta/0002_snapshot.json @@ -0,0 +1,576 @@ +{ + "id": "92579669-633b-4bec-9839-1674019645a5", + "prevId": "c81e8515-a0ed-4ce1-ad9a-bb96bf09640a", + "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()" + } + }, + "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, + "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 + } + }, + "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/0003_snapshot.json b/packages/core/src/engagement/chat/drizzle/migrations/meta/0003_snapshot.json new file mode 100644 index 00000000..da1934fc --- /dev/null +++ b/packages/core/src/engagement/chat/drizzle/migrations/meta/0003_snapshot.json @@ -0,0 +1,652 @@ +{ + "id": "79e5eb46-0b0f-4006-8e14-b713be88acfe", + "prevId": "92579669-633b-4bec-9839-1674019645a5", + "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()" + } + }, + "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, + "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()" + } + }, + "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, + "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 3fba8201..5651972f 100644 --- a/packages/core/src/engagement/chat/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/engagement/chat/drizzle/migrations/meta/_journal.json @@ -15,6 +15,20 @@ "when": 1784721152596, "tag": "0001_cloudy_groot", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1785142633864, + "tag": "0002_steep_zodiak", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1785446950360, + "tag": "0003_stormy_king_bedlam", + "breakpoints": true } ] } diff --git a/packages/core/src/engagement/chat/plugin.ts b/packages/core/src/engagement/chat/plugin.ts index ba9ae538..457d718e 100644 --- a/packages/core/src/engagement/chat/plugin.ts +++ b/packages/core/src/engagement/chat/plugin.ts @@ -1,19 +1,39 @@ import { definePlugin, EVENT_BUS, DRIZZLE, ADMIN_GUARD } from '@openora/core/server'; import { - REALTIME_TRANSPORT, - REALTIME_CLIENT_AUTHORIZER, + CHAT_REALTIME_TRANSPORT, + CHAT_REALTIME_CLIENT_AUTHORIZER, RATE_LIMITER, + CHAT_SYSTEM_WRITER, + CHAT_BLOCK_WRITER, + ADMIN_USER_DIRECTORY, + createToken, } from '@openora/core/contracts'; import { ChatService } from './service/chat.service.js'; import { createChatRouter } from './router/index.js'; +const CHAT_SERVICE = createToken('_ChatService'); + export default definePlugin({ id: 'chat', + dependsOn: ['identity'], register(ctx) { + ctx.provide( + CHAT_SERVICE, + (c) => + new ChatService( + c.get(DRIZZLE), + c.get(EVENT_BUS), + c.get(CHAT_REALTIME_TRANSPORT), + c.get(ADMIN_USER_DIRECTORY), + ), + ); + ctx.provide(CHAT_SYSTEM_WRITER, (c) => c.get(CHAT_SERVICE)); + ctx.provide(CHAT_BLOCK_WRITER, (c) => c.get(CHAT_SERVICE)); + ctx.routers.add('chat', (c) => createChatRouter({ - chatService: new ChatService(c.get(DRIZZLE), c.get(EVENT_BUS), c.get(REALTIME_TRANSPORT)), - authorizer: c.get(REALTIME_CLIENT_AUTHORIZER), + chatService: c.get(CHAT_SERVICE), + authorizer: c.get(CHAT_REALTIME_CLIENT_AUTHORIZER), adminGuard: c.get(ADMIN_GUARD), limiter: c.get(RATE_LIMITER), }), diff --git a/packages/core/src/engagement/chat/react/use-chat-stream.ts b/packages/core/src/engagement/chat/react/use-chat-stream.ts deleted file mode 100644 index 2fb5b7b5..00000000 --- a/packages/core/src/engagement/chat/react/use-chat-stream.ts +++ /dev/null @@ -1,90 +0,0 @@ -'use client'; - -import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from 'react'; -import { populateContractRouterPaths } from '@orpc/contract'; -import { - useOrpcClient, - useEventStream, - useOptionalRealtimeClient, - type EventStreamStatus, -} from '@openora/core/react'; -import { chatContract, type ChatMessage } from '../contract/index.js'; - -export type { ChatMessage }; - -const chatRootContract = populateContractRouterPaths({ chat: chatContract }); - -const DEFAULT_MAX_MESSAGES = 500; - -export type UseChatStreamOptions = { - initialMessages?: ChatMessage[]; - enabled?: boolean; - maxMessages?: number; -}; - -export type UseChatStreamResult = { - messages: ChatMessage[]; - status: EventStreamStatus; - setMessages: Dispatch>; -}; - -// MUST mirror `chatChannel` in the chat service so the injected transport subscribes to the same channel the server publishes to. -function chatChannel(roomId: string | null): string { - return roomId ? `chat:room:${roomId}` : 'chat:global'; -} - -/** - * Live chat feed for a room (`roomId: null` = the global channel). - * - * Provider-agnostic by design: when a `RealtimeClientProvider` is mounted (eg a - * consumer wiring Ably/GetStream), the feed subscribes through that injected - * adapter so the client connects directly to the vendor. Otherwise it falls back - * to the built-in first-party SSE transport (`chat.streamMessages`). Either way it - * appends each message, de-duplicating by id (delivery is at-least-once). Pair - * with an initial `getRoomMessages`/`getGlobalMessages` fetch for backfill. - */ -export function useChatStream( - roomId: string | null, - options: UseChatStreamOptions = {}, -): UseChatStreamResult { - const { initialMessages = [], enabled = true, maxMessages = DEFAULT_MAX_MESSAGES } = options; - const client = useOrpcClient(chatRootContract); - const adapter = useOptionalRealtimeClient(); - const [messages, setMessages] = useState(initialMessages); - const [adapterStatus, setAdapterStatus] = useState('idle'); - - const onMessage = useCallback( - (message: ChatMessage) => { - setMessages((prev) => { - if (prev.some((m) => m.id === message.id)) { - return prev; - } - const next = [...prev, message]; - return next.length > maxMessages ? next.slice(next.length - maxMessages) : next; - }); - }, - [maxMessages], - ); - - const subscribe = useCallback( - (signal: AbortSignal) => client.chat.streamMessages({ roomId }, { signal }), - [client, roomId], - ); - const { status: sseStatus } = useEventStream(subscribe, { - enabled: enabled && !adapter, - onEvent: onMessage, - }); - - useEffect(() => { - if (!adapter || !enabled) { - return; - } - return adapter.subscribe(chatChannel(roomId), { - onMessage, - onStatus: setAdapterStatus, - }); - }, [adapter, enabled, roomId, onMessage]); - - const status = adapter ? (enabled ? adapterStatus : 'idle') : sseStatus; - return { messages, status, setMessages }; -} diff --git a/packages/core/src/engagement/chat/react/use-join-room.ts b/packages/core/src/engagement/chat/react/use-join-room.ts deleted file mode 100644 index 733b1481..00000000 --- a/packages/core/src/engagement/chat/react/use-join-room.ts +++ /dev/null @@ -1,33 +0,0 @@ -'use client'; - -import { useCallback } from 'react'; -import { useQueryClient } from '@tanstack/react-query'; -import { useOptionalRealtimeClient } from '@openora/core/react'; -import type { ChatRoom } from '../contract/index.js'; - -/** - * Joins a private room by join code, then refreshes both the room list cache and the - * realtime connection token so the new channel is included in the next Ably/vendor grant. - * - * Usage: - * const join = useJoinRoom((input) => client.chat.joinRoom(input)); - * await join('ABC123'); - */ -export function useJoinRoom( - joinRoomFn: (input: { joinCode: string }) => Promise, -): (joinCode: string) => Promise { - const queryClient = useQueryClient(); - const adapter = useOptionalRealtimeClient(); - - return useCallback( - async (joinCode: string): Promise => { - const room = await joinRoomFn({ joinCode }); - // Refresh the vendor token first so the new channel is authorised before the - // room list re-renders and the consumer subscribes to it (BF-75 / ADR-0007). - adapter?.refresh?.(); - void queryClient.invalidateQueries({ queryKey: ['chat', 'rooms'] }); - return room; - }, - [joinRoomFn, queryClient, adapter], - ); -} diff --git a/packages/core/src/engagement/chat/router/index.ts b/packages/core/src/engagement/chat/router/index.ts index b5f7f3da..3732ca40 100644 --- a/packages/core/src/engagement/chat/router/index.ts +++ b/packages/core/src/engagement/chat/router/index.ts @@ -11,14 +11,15 @@ import { import { makeRateLimitKey, RATE_LIMIT_KEYS, + chatChannel, type RateLimiterAdapter, type RealtimeClientAuthorizer, } from '@openora/core/contracts'; import { chatContract } from '../contract/index.js'; import { ChatService, - chatChannel, ChatRoomNotFoundError, + ChatRoomOwnershipError, ChatRoomNotMemberError, ChatRoomNotModeratorError, ChatRoomSelfModerationError, @@ -28,6 +29,7 @@ import { ChatMessageOwnershipError, ChatMessageBlockedError, ChatSelfBlockError, + ChatSelfIgnoreError, ChatRoomSlugConflictError, ChatRoomJoinCodeNotFoundError, ChatRoomBannedError, @@ -193,6 +195,21 @@ export function createChatRouter({ return chatService.unblockUser(getUserId(context), input.blockedId, context.clientMeta); }), + listIgnoredUsers: os.listIgnoredUsers.handler(({ context }) => + chatService.listIgnoredUsers(getUserId(context)), + ), + + ignoreUser: os.ignoreUser.handler(({ input, context }) => { + const userId = getUserId(context); + return mapErrors({ BAD_REQUEST: ChatSelfIgnoreError }, () => + chatService.ignoreUser(userId, input.ignoredId, context.clientMeta), + ); + }), + + unignoreUser: os.unignoreUser.handler(({ input, context }) => { + return chatService.unignoreUser(getUserId(context), input.ignoredId, context.clientMeta); + }), + createPrivateRoom: os.createPrivateRoom.handler(({ input, context }) => { const userId = getUserId(context); return mapErrors({ CONFLICT: ChatRoomLimitReachedError }, () => @@ -200,6 +217,15 @@ export function createChatRouter({ ); }), + deletePrivateRoom: os.deletePrivateRoom.handler(({ input, context }) => { + const userId = getUserId(context); + return mapErrors( + { NOT_FOUND: ChatRoomNotFoundError, FORBIDDEN: ChatRoomOwnershipError }, + () => + chatService.deletePrivateRoom({ roomId: input.roomId, userId, ...context.clientMeta }), + ); + }), + joinRoom: os.joinRoom.handler(async ({ input, context }) => { const userId = getUserId(context); await assertRateLimit( diff --git a/packages/core/src/engagement/chat/schema/index.ts b/packages/core/src/engagement/chat/schema/index.ts index 25669cf2..a6b69828 100644 --- a/packages/core/src/engagement/chat/schema/index.ts +++ b/packages/core/src/engagement/chat/schema/index.ts @@ -4,14 +4,18 @@ import { uuid, text, boolean, + jsonb, timestamp, uniqueIndex, index, } from 'drizzle-orm/pg-core'; import { CHAT_ROOM_CATEGORIES, CHAT_ROOM_ROLES } from '../contract/index.js'; +import { CHAT_MESSAGE_TYPES } from '@openora/core/contracts'; +import type { CommandMetadata } from '@openora/core/contracts'; export const chatRoomRole = pgEnum('chat_room_role', CHAT_ROOM_ROLES); export const chatRoomCategory = pgEnum('chat_room_category', CHAT_ROOM_CATEGORIES); +export const chatMessageType = pgEnum('chat_message_type', CHAT_MESSAGE_TYPES); export const chatRoom = pgTable( 'chat_room', @@ -43,6 +47,8 @@ export const chatMessage = pgTable( userId: uuid().notNull(), username: text().notNull(), content: text().notNull(), + type: chatMessageType().notNull().default('user'), + metadata: jsonb().$type(), isDeleted: boolean().notNull().default(false), createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), }, @@ -68,6 +74,23 @@ export const chatUserBlock = pgTable( ], ); +// 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. +export const chatUserIgnore = pgTable( + 'chat_user_ignore', + { + id: uuid().primaryKey().defaultRandom(), + ignorerId: uuid().notNull(), + ignoredId: uuid().notNull(), + createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + uniqueIndex('chat_user_ignore_pair_key').on(t.ignorerId, t.ignoredId), + index('chat_user_ignore_ignorer_idx').on(t.ignorerId), + ], +); + export const chatRoomMember = pgTable( 'chat_room_member', { @@ -106,5 +129,6 @@ export const chatRoomBan = pgTable( export type ChatRoom = typeof chatRoom.$inferSelect; export type ChatMessage = typeof chatMessage.$inferSelect; export type ChatUserBlock = typeof chatUserBlock.$inferSelect; +export type ChatUserIgnore = typeof chatUserIgnore.$inferSelect; export type ChatRoomMember = typeof chatRoomMember.$inferSelect; export type ChatRoomBan = typeof chatRoomBan.$inferSelect; diff --git a/packages/core/src/engagement/chat/service/chat.service.ts b/packages/core/src/engagement/chat/service/chat.service.ts index 1be3933a..9a024523 100644 --- a/packages/core/src/engagement/chat/service/chat.service.ts +++ b/packages/core/src/engagement/chat/service/chat.service.ts @@ -11,8 +11,16 @@ import { serializeRow, pageToOffset, withAdvisoryXactLock, + type DrizzleDb, } from '@openora/core/server'; -import type { ClientMeta, RealtimeTransport } from '@openora/core/contracts'; +import type { + ClientMeta, + RealtimeTransport, + CommandMetadata, + ChatSystemMessage, + AdminUserDirectory, +} from '@openora/core/contracts'; +import { chatChannel } from '@openora/core/contracts'; import { eq, and, isNull, lt, desc, asc, notInArray, inArray, count, ne } from 'drizzle-orm'; import { user } from '@openora/core/pam/schema/identity'; import type { User } from '@openora/core/pam/schema/identity'; @@ -20,6 +28,7 @@ import { chatRoom, chatMessage, chatUserBlock, + chatUserIgnore, chatRoomMember, chatRoomBan, } from '../schema/index.js'; @@ -39,11 +48,8 @@ import { } from '../contract/constants.js'; import { moderateContent } from '../moderation/index.js'; -export function chatChannel(roomId: ChatRoom['id'] | null) { - return roomId ? `chat:room:${roomId}` : 'chat:global'; -} - export const ChatRoomNotFoundError = makeNotFoundError('ChatRoom'); +export const ChatRoomOwnershipError = makeOwnershipError('ChatRoom'); export const ChatMessageNotFoundError = makeNotFoundError('ChatMessage'); export const ChatMessageOwnershipError = makeOwnershipError('ChatMessage'); @@ -57,6 +63,11 @@ export const ChatSelfBlockError = createDomainError( () => 'You cannot block yourself', ); +export const ChatSelfIgnoreError = createDomainError( + 'ChatSelfIgnoreError', + () => 'You cannot ignore yourself', +); + export const ChatRoomSlugConflictError = makeConflictError( 'ChatRoomSlugConflictError', 'A room with this slug already exists', @@ -142,6 +153,7 @@ export class ChatService { private readonly drizzle: DrizzleService, private readonly events: EventBus, private readonly transport: RealtimeTransport, + private readonly directory: AdminUserDirectory, ) {} subscribeMessages( @@ -162,7 +174,7 @@ export class ChatService { } }; if (viewerId) { - this.blockedIdsFor(viewerId) + this.excludedSenderIdsFor(viewerId) .catch(() => new Set()) .then((ids) => { blocked = ids; @@ -198,6 +210,22 @@ export class ChatService { return new Set(rows.map((r) => r.blockedId)); } + private async ignoredIdsFor(viewerId: User['id']) { + const rows = await this.drizzle.db + .select({ ignoredId: chatUserIgnore.ignoredId }) + .from(chatUserIgnore) + .where(eq(chatUserIgnore.ignorerId, viewerId)); + return new Set(rows.map((r) => r.ignoredId)); + } + + private async excludedSenderIdsFor(viewerId: User['id']) { + const [blocked, ignored] = await Promise.all([ + this.blockedIdsFor(viewerId), + this.ignoredIdsFor(viewerId), + ]); + return new Set([...blocked, ...ignored]); + } + private async resolveDisplayName(userId: User['id'], fallback: string) { const [row] = await this.drizzle.db .select({ name: user.name }) @@ -320,7 +348,7 @@ export class ChatService { if (before) { conditions.push(lt(chatMessage.createdAt, new Date(before))); } - await this.appendBlockFilter(conditions, viewerId); + await this.appendExcludedSendersFilter(conditions, viewerId); const messages = await this.drizzle.db .select() .from(chatMessage) @@ -330,13 +358,16 @@ export class ChatService { return messages.map(toMessage); } - private async appendBlockFilter(conditions: ReturnType[], viewerId?: User['id']) { + private async appendExcludedSendersFilter( + conditions: ReturnType[], + viewerId?: User['id'], + ) { if (!viewerId) { return; } - const blocked = await this.blockedIdsFor(viewerId); - if (blocked.size > 0) { - conditions.push(notInArray(chatMessage.userId, [...blocked])); + const excluded = await this.excludedSenderIdsFor(viewerId); + if (excluded.size > 0) { + conditions.push(notInArray(chatMessage.userId, [...excluded])); } } @@ -394,7 +425,7 @@ export class ChatService { async getGlobalMessages(limit = DEFAULT_MESSAGE_LIMIT, viewerId?: User['id']) { const conditions = [isNull(chatMessage.roomId), eq(chatMessage.isDeleted, false)]; - await this.appendBlockFilter(conditions, viewerId); + await this.appendExcludedSendersFilter(conditions, viewerId); const messages = await this.drizzle.db .select() .from(chatMessage) @@ -478,6 +509,59 @@ 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 ignoreUser(ignorerId: User['id'], ignoredId: User['id'], meta?: ClientMeta) { + if (ignorerId === ignoredId) { + throw new ChatSelfIgnoreError(); + } + + // Idempotent: re-ignoring is a no-op, so only the first ignore emits an event. + const inserted = await this.drizzle.db + .insert(chatUserIgnore) + .values({ ignorerId, ignoredId }) + .onConflictDoNothing({ target: [chatUserIgnore.ignorerId, chatUserIgnore.ignoredId] }) + .returning(); + + if (inserted.length > 0) { + this.events.emit('chat.user.ignored', { + ignorerId, + ignoredId, + ip: meta?.ip ?? null, + userAgent: meta?.userAgent ?? null, + }); + } + return { success: true } as const; + } + + async unignoreUser(ignorerId: User['id'], ignoredId: User['id'], meta?: ClientMeta) { + const removed = await this.drizzle.db + .delete(chatUserIgnore) + .where(and(eq(chatUserIgnore.ignorerId, ignorerId), eq(chatUserIgnore.ignoredId, ignoredId))) + .returning(); + + if (removed.length > 0) { + this.events.emit('chat.user.unignored', { + ignorerId, + ignoredId, + ip: meta?.ip ?? null, + userAgent: meta?.userAgent ?? null, + }); + } + return { success: true } as const; + } + + async getExcludedUserIds(viewerId: User['id']): Promise { + return [...(await this.excludedSenderIdsFor(viewerId))]; + } + async createRoom({ name, slug, @@ -593,6 +677,42 @@ export class ChatService { return toRoom(updated); } + async deletePrivateRoom({ + roomId, + userId, + ip, + userAgent, + }: { + roomId: ChatRoom['id']; + userId: User['id']; + } & ClientMeta) { + const room = findOneOrThrow( + await this.drizzle.db + .select() + .from(chatRoom) + .where( + and(eq(chatRoom.id, roomId), eq(chatRoom.isPublic, false), isNull(chatRoom.deletedAt)), + ) + .limit(1), + new ChatRoomNotFoundError(roomId), + ); + if (!room.creatorId) { + throw new ChatRoomOwnershipError(); + } + assertOwnership(room.creatorId, userId, new ChatRoomOwnershipError()); + await this.drizzle.db + .update(chatRoom) + .set({ deletedAt: new Date() }) + .where(eq(chatRoom.id, roomId)); + this.events.emit('chat.private_room.deleted', { + roomId, + creatorId: userId, + ip: ip ?? null, + userAgent: userAgent ?? null, + }); + return { success: true } as const; + } + async deleteRoom(id: ChatRoom['id'], actorId?: User['id'], meta?: ClientMeta) { const deleted = findOneOrThrow( await this.drizzle.db @@ -874,6 +994,42 @@ export class ChatService { return { success: true } as const; } + async postSystemMessage(args: { + roomId: ChatRoom['id'] | null; + actorId: User['id']; + metadata: CommandMetadata; + tx?: unknown; + }): Promise { + const db = (args.tx as DrizzleDb | undefined) ?? this.drizzle.db; + const [record] = await db + .insert(chatMessage) + .values({ + roomId: args.roomId, + userId: args.actorId, + username: 'system', + content: '', + type: 'system', + metadata: args.metadata, + }) + .returning(); + const msg: ChatSystemMessage = { + id: record.id, + roomId: record.roomId ?? null, + actorId: args.actorId, + content: record.content, + metadata: args.metadata, + createdAt: record.createdAt.toISOString(), + }; + // Only auto-publish when this call owns the write (no caller-managed transaction). + // When `tx` is passed, the caller's transaction hasn't committed yet - publishing + // here would leak a message to clients before (or even if) it actually commits. + // The caller must publish itself after its transaction resolves. + if (!args.tx) { + void this.transport.publish(chatChannel(args.roomId), msg); + } + return msg; + } + async listRoomMembers({ roomId, viewerId }: { roomId: ChatRoom['id']; viewerId?: User['id'] }) { await this.verifyRoomAccess(roomId, viewerId); const members = await this.drizzle.db @@ -885,6 +1041,13 @@ export class ChatService { .from(chatRoomMember) .where(eq(chatRoomMember.roomId, roomId)) .orderBy(asc(chatRoomMember.joinedAt)); - return members.map((m) => serializeRow(m, { dateFields: ['joinedAt'] })); + const summaries = await this.directory.lookupPlayers(members.map((m) => m.userId)); + const usernameByUserId = new Map(summaries.map((s) => [s.userId, s.username])); + return members.map((m) => + serializeRow( + { ...m, username: usernameByUserId.get(m.userId) ?? null }, + { dateFields: ['joinedAt'] }, + ), + ); } } diff --git a/packages/core/src/engagement/contracts.ts b/packages/core/src/engagement/contracts.ts index 192d94d4..c4131e72 100644 --- a/packages/core/src/engagement/contracts.ts +++ b/packages/core/src/engagement/contracts.ts @@ -1,2 +1,3 @@ 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'; diff --git a/packages/core/src/engagement/index.ts b/packages/core/src/engagement/index.ts index 192d94d4..c4131e72 100644 --- a/packages/core/src/engagement/index.ts +++ b/packages/core/src/engagement/index.ts @@ -1,2 +1,3 @@ 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'; diff --git a/packages/core/src/engagement/react.ts b/packages/core/src/engagement/react.ts deleted file mode 100644 index 35c2030a..00000000 --- a/packages/core/src/engagement/react.ts +++ /dev/null @@ -1,8 +0,0 @@ -// `@openora/engagement/react` - domain-owned hooks, keeping the base @openora/core/react SDK domain-agnostic. -export { - useChatStream, - type ChatMessage, - type UseChatStreamOptions, - type UseChatStreamResult, -} from './chat/react/use-chat-stream.js'; -export { useJoinRoom } from './chat/react/use-join-room.js'; diff --git a/packages/core/src/engagement/server.ts b/packages/core/src/engagement/server.ts index 996946f5..9f3d87b5 100644 --- a/packages/core/src/engagement/server.ts +++ b/packages/core/src/engagement/server.ts @@ -1,2 +1,3 @@ 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'; diff --git a/packages/core/src/pam/identity/__tests__/admin-user-directory.test.ts b/packages/core/src/pam/identity/__tests__/admin-user-directory.test.ts index e056b62c..b13777a4 100644 --- a/packages/core/src/pam/identity/__tests__/admin-user-directory.test.ts +++ b/packages/core/src/pam/identity/__tests__/admin-user-directory.test.ts @@ -185,6 +185,10 @@ describe('DrizzleAdminUserDirectory.lookupPlayers (real PG)', () => { email: 'alice@example.com', kycStatus: 'approved', language: 'en', + avatarUrl: null, + createdAt: expect.any(Date), + level: 1, + currency: 'USD', }); }); @@ -196,6 +200,33 @@ describe('DrizzleAdminUserDirectory.lookupPlayers (real PG)', () => { }); }); +describe('DrizzleAdminUserDirectory.getPlayerByUsername (real PG)', () => { + it('returns an exact case-insensitive match', async () => { + const { dir } = makeDirectory(); + const account = await seedUser({ email: 'anna@example.com' }); + await seedPlayer(account.id, { displayName: 'AnnaBell' }); + + const summary = await dir.getPlayerByUsername('annabell'); + + expect(summary).toMatchObject({ userId: account.id, username: 'AnnaBell' }); + }); + + it('returns null when no player matches', async () => { + const { dir } = makeDirectory(); + + expect(await dir.getPlayerByUsername('ghost')).toBeNull(); + }); + + it('does not substring-match, unlike findPlayerIds', async () => { + const { dir } = makeDirectory(); + const account = await seedUser({ email: 'annabell@example.com' }); + await seedPlayer(account.id, { displayName: 'AnnaBell' }); + + expect(await dir.getPlayerByUsername('Anna')).toBeNull(); + expect(await dir.findPlayerIds('Anna')).toEqual([account.id]); + }); +}); + describe('DrizzleAdminUserDirectory.findPlayerIds (real PG)', () => { it('unions email and displayName matches into a deduped id set', async () => { const { dir } = makeDirectory(); diff --git a/packages/core/src/pam/identity/admin-user-directory.ts b/packages/core/src/pam/identity/admin-user-directory.ts index 25c28cf1..a6844458 100644 --- a/packages/core/src/pam/identity/admin-user-directory.ts +++ b/packages/core/src/pam/identity/admin-user-directory.ts @@ -2,7 +2,7 @@ import type { AdminUserDirectory, AdminUserListOptions, ClientMeta } from '@open import { KycStatusSchema, normalizeKycStatus } from '@openora/core/contracts'; import { DrizzleService, pageToOffset } from '@openora/core/server'; import type { EventBus } from '@openora/core/server'; -import { asc, count, desc, eq, ilike, inArray } from 'drizzle-orm'; +import { asc, count, desc, eq, ilike, inArray, sql } from 'drizzle-orm'; import { user } from './schema/index.js'; // Read-only cross-domain read of the player/profile table via the public /schema // subpath (allowed per ADR-0020) so back-office lists can label players by @@ -122,6 +122,10 @@ export class DrizzleAdminUserDirectory implements AdminUserDirectory { kycStatus: player.kycStatus, email: user.email, language: user.language, + avatarUrl: user.image, + createdAt: player.createdAt, + level: player.level, + currency: player.currency, }) .from(player) .innerJoin(user, eq(player.userId, user.id)) @@ -140,19 +144,49 @@ export class DrizzleAdminUserDirectory implements AdminUserDirectory { }); } + async getPlayerByUsername(username: string) { + const rows = await this.drizzle.db + .select({ + userId: player.userId, + username: player.displayName, + kycStatus: player.kycStatus, + email: user.email, + language: user.language, + avatarUrl: user.image, + createdAt: player.createdAt, + level: player.level, + currency: player.currency, + }) + .from(player) + .innerJoin(user, eq(player.userId, user.id)) + // Exact, case-insensitive match - ILIKE would let % and _ in `username` act as + // wildcards, letting a caller-supplied pattern select an arbitrary matching player. + .where(eq(sql`lower(${player.displayName})`, username.toLowerCase())) + .limit(1); + const [row] = rows; + if (!row) { + return null; + } + const kyc = KycStatusSchema.safeParse(row.kycStatus); + return { ...row, kycStatus: kyc.success ? normalizeKycStatus(kyc.data) : null }; + } + // Substring search is index-backed by the pg_trgm GIN indexes on user.email and // player.display_name; the 1000 cap is a safety bound on the id set, not a perf // crutch - it is effectively unreachable for a real admin search term. - async findPlayerIds(query: string) { + async findPlayerIds(query: string, limit = 1000) { const term = `%${query}%`; - const cap = 1000; const [byEmail, byName] = await Promise.all([ - this.drizzle.db.select({ id: user.id }).from(user).where(ilike(user.email, term)).limit(cap), + this.drizzle.db + .select({ id: user.id }) + .from(user) + .where(ilike(user.email, term)) + .limit(limit), this.drizzle.db .select({ userId: player.userId }) .from(player) .where(ilike(player.displayName, term)) - .limit(cap), + .limit(limit), ]); const ids = new Set(); for (const r of byEmail) { @@ -161,6 +195,6 @@ export class DrizzleAdminUserDirectory implements AdminUserDirectory { for (const r of byName) { ids.add(r.userId); } - return [...ids].slice(0, cap); + return [...ids].slice(0, limit); } } diff --git a/packages/core/src/react/hooks/__tests__/use-event-stream.test.ts b/packages/core/src/react/hooks/__tests__/use-event-stream.test.ts new file mode 100644 index 00000000..a0d74f10 --- /dev/null +++ b/packages/core/src/react/hooks/__tests__/use-event-stream.test.ts @@ -0,0 +1,193 @@ +// @vitest-environment jsdom +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useEventStream } from '../use-event-stream.js'; + +type Controllable = { + iterable: AsyncIterable; + push: (event: T) => void; + end: () => void; +}; + +function createControllableIterable(): Controllable { + const queue: T[] = []; + let resolveNext: (() => void) | null = null; + let done = false; + + const wake = () => { + resolveNext?.(); + resolveNext = null; + }; + + const iterable: AsyncIterable = { + [Symbol.asyncIterator]() { + return { + async next(): Promise> { + while (queue.length === 0 && !done) { + await new Promise((resolve) => { + resolveNext = resolve; + }); + } + if (queue.length > 0) { + return { value: queue.shift() as T, done: false }; + } + return { value: undefined, done: true }; + }, + }; + }, + }; + + return { + iterable, + push: (event) => { + queue.push(event); + wake(); + }, + end: () => { + done = true; + wake(); + }, + }; +} + +function endedIterable(): AsyncIterable { + const stream = createControllableIterable(); + stream.end(); + return stream.iterable; +} + +async function flush(ms = 0) { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms); + }); +} + +describe('useEventStream', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('goes idle -> connecting -> open and delivers events', async () => { + const stream = createControllableIterable<{ id: number }>(); + const subscribe = vi.fn().mockResolvedValue(stream.iterable); + + const { result } = renderHook(() => useEventStream(subscribe)); + expect(result.current.status).toBe('connecting'); + + await flush(); + expect(result.current.status).toBe('open'); + + await act(async () => { + stream.push({ id: 1 }); + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.last).toEqual({ id: 1 }); + }); + + it('grows the retry delay on repeated accept-then-immediate-drop cycles instead of pinning it at 1s', async () => { + const callTimes: number[] = []; + const subscribe = vi.fn().mockImplementation(async () => { + callTimes.push(Date.now()); + return endedIterable(); + }); + + renderHook(() => useEventStream(subscribe)); + + await flush(); + await flush(1_000); + await flush(2_000); + await flush(4_000); + + expect(callTimes).toHaveLength(4); + expect(callTimes[1]! - callTimes[0]!).toBe(1_000); + expect(callTimes[2]! - callTimes[1]!).toBe(2_000); + expect(callTimes[3]! - callTimes[2]!).toBe(4_000); + }); + + it('surfaces status "closed" on an unexpected drop, before the next reconnect attempt', async () => { + const subscribe = vi.fn().mockImplementation(async () => endedIterable()); + + const { result } = renderHook(() => useEventStream(subscribe)); + + await flush(); + expect(result.current.status).toBe('closed'); + }); + + it('resets the backoff once a connection has stayed open past the stability window', async () => { + const callTimes: number[] = []; + let call = 0; + const streams: Controllable[] = []; + const subscribe = vi.fn().mockImplementation(async () => { + callTimes.push(Date.now()); + call += 1; + if (call === 1) { + // First cycle: accepted then dropped immediately - retryCount -> 1, next delay 1000ms. + return endedIterable(); + } + // Second cycle: stays open - the test advances past the stability window before ending it. + const stream = createControllableIterable(); + streams.push(stream); + return stream.iterable; + }); + + renderHook(() => useEventStream(subscribe)); + + await flush(); // cycle 1 opens then immediately closes + await flush(1_000); // cycle 2 starts (base 1s delay confirms cycle 1 pinned retryCount at 1) + + // Let cycle 2 earn stability (5s), then end it - a genuinely reset backoff + // reconnects at the base 1s delay again, not 2000ms (2^1). + await flush(5_000); + await act(async () => { + streams[0]?.end(); + await vi.advanceTimersByTimeAsync(0); + }); + await flush(1_000); + + expect(callTimes).toHaveLength(3); + expect(callTimes[1]! - callTimes[0]!).toBe(1_000); + expect(callTimes[2]! - callTimes[1]!).toBe(6_000); // 5s stabilizing + 1s reset-base retry + }); + + it('does not schedule a reconnect on intentional teardown', async () => { + const stream = createControllableIterable(); + const subscribe = vi.fn().mockResolvedValue(stream.iterable); + + const { unmount } = renderHook(() => useEventStream(subscribe)); + await flush(); + expect(subscribe).toHaveBeenCalledOnce(); + + unmount(); + await flush(60_000); + expect(subscribe).toHaveBeenCalledOnce(); + }); + + it('tears down without reconnecting when disabled, and resumes cleanly once re-enabled', async () => { + const subscribe = vi.fn().mockImplementation(async () => createControllableIterable().iterable); + + const { result, rerender } = renderHook( + ({ enabled }) => useEventStream(subscribe, { enabled }), + { + initialProps: { enabled: true }, + }, + ); + await flush(); + expect(result.current.status).toBe('open'); + expect(subscribe).toHaveBeenCalledOnce(); + + rerender({ enabled: false }); + expect(result.current.status).toBe('idle'); + + await flush(60_000); + expect(subscribe).toHaveBeenCalledOnce(); + + rerender({ enabled: true }); + await flush(); + expect(result.current.status).toBe('open'); + expect(subscribe).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/core/src/react/hooks/use-event-stream.ts b/packages/core/src/react/hooks/use-event-stream.ts index cf646cfb..f093dfc4 100644 --- a/packages/core/src/react/hooks/use-event-stream.ts +++ b/packages/core/src/react/hooks/use-event-stream.ts @@ -16,14 +16,17 @@ export type UseEventStreamResult = { status: EventStreamStatus; }; +const MAX_RETRY_DELAY_MS = 30_000; +const STABLE_CONNECTION_MS = 5_000; + /** * Generic client-side real-time transport. Consumes any async-iterable source * (eg an oRPC event iterator served as SSE: `client.x.stream(undefined, { signal })`). * * Owns an AbortController per active subscription, loops `for await` over the - * iterable, calls `onEvent` and tracks the latest value + connection status, - * and aborts on unmount or when `enabled` flips false. Re-subscribes when - * `subscribe` identity changes, so memoize it at the call site. + * iterable, calls `onEvent` and tracks the latest value + connection status. + * Re-subscribes when `subscribe` identity changes, so memoize it at the call site. + * Reconnects automatically with exponential backoff when the stream drops unexpectedly. */ export function useEventStream( subscribe: (signal: AbortSignal) => Promise>, @@ -43,39 +46,72 @@ export function useEventStream( return; } - const controller = new AbortController(); let cancelled = false; - setStatus('connecting'); + let currentController: AbortController | null = null; + let retryCount = 0; + let retryTimer: ReturnType | undefined; - (async () => { - try { - const iterable = await subscribe(controller.signal); - if (cancelled) { - return; - } - setStatus('open'); - for await (const event of iterable) { + const connect = () => { + const controller = new AbortController(); + currentController = controller; + setStatus('connecting'); + + (async () => { + let stableTimer: ReturnType | undefined; + let stabilized = false; + const markStable = () => { + if (stabilized) { + return; + } + stabilized = true; + retryCount = 0; + clearTimeout(stableTimer); + }; + + try { + const iterable = await subscribe(controller.signal); if (cancelled) { - break; + return; + } + setStatus('open'); + // Only trust this connection - and reset the backoff - once it has + // stayed open a while or delivered something. Resetting the instant + // subscribe() resolves means a connection accepted then dropped + // immediately (idle-timeout proxy, server ending the generator + // early) reconnects at a fixed ~1s delay forever instead of backing off. + stableTimer = setTimeout(markStable, STABLE_CONNECTION_MS); + for await (const event of iterable) { + if (cancelled) { + break; + } + markStable(); + onEventRef.current?.(event); + setLast(event); + } + } catch (err) { + // Intentional teardown — do not reconnect. + if (cancelled || (err as Error)?.name === 'AbortError') { + return; + } + } finally { + clearTimeout(stableTimer); + if (!cancelled) { + // Unexpected close: reconnect with exponential backoff. + setStatus('closed'); + const delay = Math.min(1000 * 2 ** retryCount, MAX_RETRY_DELAY_MS); + retryCount += 1; + retryTimer = setTimeout(connect, delay); } - onEventRef.current?.(event); - setLast(event); - } - } catch (err) { - // An AbortError on unmount/disable is expected teardown - swallow it. - if (!cancelled && (err as Error)?.name !== 'AbortError') { - // Stream closed; status is updated in finally. - } - } finally { - if (!cancelled) { - setStatus('closed'); } - } - })(); + })(); + }; + + connect(); return () => { cancelled = true; - controller.abort(); + clearTimeout(retryTimer); + currentController?.abort(); }; }, [subscribe, enabled]); diff --git a/packages/core/src/server/kernel/__tests__/realtime-transport.test.ts b/packages/core/src/server/kernel/__tests__/realtime-transport.test.ts index d8f9099b..ef6bb775 100644 --- a/packages/core/src/server/kernel/__tests__/realtime-transport.test.ts +++ b/packages/core/src/server/kernel/__tests__/realtime-transport.test.ts @@ -1,5 +1,17 @@ import { describe, it, expect } from 'vitest'; import { InProcessRealtimeTransport } from '../realtime-transport.js'; +import { runRealtimeTransportConformanceSuite } from '../../../testing/realtime-transport-conformance.js'; + +runRealtimeTransportConformanceSuite({ + name: 'InProcessRealtimeTransport', + create: () => new InProcessRealtimeTransport(), + supportsServerSideSubscribe: true, + addPresence: (transport, channel, entries) => { + for (const { userId, connectionId } of entries) { + transport.presence?.join(channel, userId, connectionId); + } + }, +}); describe('InProcessRealtimeTransport', () => { it('fans a published event out to every subscriber of the channel', () => { diff --git a/packages/core/src/server/kernel/realtime-transport.ts b/packages/core/src/server/kernel/realtime-transport.ts index 1b99bf82..cf91357e 100644 --- a/packages/core/src/server/kernel/realtime-transport.ts +++ b/packages/core/src/server/kernel/realtime-transport.ts @@ -31,12 +31,22 @@ class InProcessPresence implements RealtimePresence { count(channel: string): number { return this.members.get(channel)?.size ?? 0; } + + /** Returns authenticated member IDs (excludes anonymous:* entries). */ + getUserIds(channel: string): string[] { + const channelMembers = this.members.get(channel); + if (!channelMembers) { + return []; + } + return Array.from(channelMembers.keys()).filter((id) => !id.startsWith('anonymous:')); + } } /** First-party process-local fan-out used by the default SSE transport. */ export class InProcessRealtimeTransport implements RealtimeTransport { private readonly channels = new Map>(); - readonly presence: RealtimePresence = new InProcessPresence(); + private readonly inProcessPresence = new InProcessPresence(); + readonly presence: RealtimePresence = this.inProcessPresence; publish(channel: string, event: T): void { const subscribers = this.channels.get(channel); @@ -68,4 +78,8 @@ export class InProcessRealtimeTransport implements RealtimeTransport { } }; } + + getOnlineUserIds(channel: string): Promise { + return Promise.resolve(this.inProcessPresence.getUserIds(channel)); + } } diff --git a/packages/core/src/server/runtime/create-app.ts b/packages/core/src/server/runtime/create-app.ts index 2ddb0a39..338f5233 100644 --- a/packages/core/src/server/runtime/create-app.ts +++ b/packages/core/src/server/runtime/create-app.ts @@ -15,8 +15,6 @@ import { RedisCache, RedisRateLimiter, RedisStreamsBroker, - InProcessRealtimeTransport, - SseClientAuthorizer, createEventBus, createLogger, createRedisClient, @@ -40,8 +38,6 @@ import { IGAMING_CONFIG, type IgamingConfig, PLATFORM_CONFIG, - REALTIME_TRANSPORT, - REALTIME_CLIENT_AUTHORIZER, } from '@openora/core/contracts'; import { DrizzleService, DRIZZLE, DrizzleOutboxWriter, OutboxRelay } from '../db/index.js'; import { AdminGuard, ADMIN_GUARD, SessionResolver, AUTH_SESSION } from '../auth/index.js'; @@ -236,8 +232,6 @@ export async function createApp(config: CreateAppConfig): Promise { outboxEnabled ? c.get(OUTBOX) : undefined, ), ); - container.register(REALTIME_TRANSPORT, () => new InProcessRealtimeTransport()); - container.register(REALTIME_CLIENT_AUTHORIZER, () => new SseClientAuthorizer()); // When REDIS_URL is set, JOB_QUEUE, the rate limiter, the cache and the message // broker bind to the shipped Redis reference adapters: BullMQ-backed durable jobs // (survive restarts, real cron), distributed throttling, cross-replica cache diff --git a/packages/core/src/testing/index.ts b/packages/core/src/testing/index.ts index e015893e..03b83d7d 100644 --- a/packages/core/src/testing/index.ts +++ b/packages/core/src/testing/index.ts @@ -1,4 +1,8 @@ export { InProcessRealtimeTransport, SseClientAuthorizer } from '@openora/core/server'; +export { + runRealtimeTransportConformanceSuite, + type RealtimeTransportHarness, +} from './realtime-transport-conformance.js'; export { createTestDb, createTestRedis, diff --git a/packages/core/src/testing/mock.ts b/packages/core/src/testing/mock.ts index fba4bfd4..6fb98dd0 100644 --- a/packages/core/src/testing/mock.ts +++ b/packages/core/src/testing/mock.ts @@ -58,14 +58,14 @@ const CHAIN_METHODS = [ /** Chainable, awaitable Drizzle double: awaiting the builder pops the next `select` queue * entry, `.returning()` pops the `returning` queue - a test supplies per-statement results * in call order. */ -const makeQueryBuilder = (results: { select: Row[][]; returning: Row[][] }) => { +const makeQueryBuilder = (results: { select: Row[][]; returning: Row[][]; execute: Row[][] }) => { const builder: Record = {}; const chain = () => builder; for (const m of CHAIN_METHODS) { builder[m] = vi.fn(chain); } builder['returning'] = vi.fn(() => Promise.resolve(results.returning.shift() ?? [])); - builder['execute'] = vi.fn(() => Promise.resolve({ rows: [] })); + builder['execute'] = vi.fn(() => Promise.resolve({ rows: results.execute?.shift() ?? [] })); // oxlint-disable-next-line unicorn/no-thenable -- the builder must be awaitable to mimic Drizzle. builder['then'] = (resolve: (v: Row[]) => unknown) => resolve(results.select.shift() ?? []); return builder; @@ -73,8 +73,14 @@ const makeQueryBuilder = (results: { select: Row[][]; returning: Row[][] }) => { /** Fake `DrizzleService` for tests asserting call order/shape rather than committed state - * reach for `createTestDb` when a real unique index, lock or transaction outcome matters. */ -export const makeDrizzle = (results: { select?: Row[][]; returning?: Row[][] } = {}) => { - const state = { select: results.select ?? [], returning: results.returning ?? [] }; +export const makeDrizzle = ( + results: { select?: Row[][]; returning?: Row[][]; execute?: Row[][] } = {}, +) => { + const state = { + select: results.select ?? [], + returning: results.returning ?? [], + execute: results.execute ?? [], + }; const builder = makeQueryBuilder(state); const db = { ...builder, diff --git a/packages/core/src/testing/realtime-transport-conformance.ts b/packages/core/src/testing/realtime-transport-conformance.ts new file mode 100644 index 00000000..e42f2e32 --- /dev/null +++ b/packages/core/src/testing/realtime-transport-conformance.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; +import type { RealtimeTransport } from '@openora/core/contracts'; + +export type RealtimeTransportHarness = { + /** Used only in describe() block naming for readable output. */ + name: string; + create: () => RealtimeTransport; + /** + * False for a transport whose subscribe() is a server-side no-op because + * clients connect directly to a managed vendor's edge (eg AblyRealtimeTransport). + * Gates the publish/subscribe fan-out assertions. + */ + supportsServerSideSubscribe: boolean; + /** + * Seeds the harness's notion of "these users are online in this channel" so + * getOnlineUserIds() can be asserted the same way across a transport that + * tracks join/leave itself (in-process) and one that reads a vendor-side + * presence set instead (Ably) - the entries stand in for real connections + * regardless of how the transport backs them. + */ + addPresence?: ( + transport: RealtimeTransport, + channel: string, + entries: { userId: string; connectionId: string }[], + ) => void | Promise; + /** + * Forces the harness's next presence/online-user call to fail, to assert + * graceful degradation (never throws/rejects, returns a safe default). + */ + simulateFailure?: (transport: RealtimeTransport) => void; +}; + +/** + * Shared behavioral spec for every RealtimeTransport implementation. Run this + * against each adapter alongside its own adapter-specific tests (this suite + * doesn't replace those) - it only proves the two satisfy the SAME contract. + */ +export function runRealtimeTransportConformanceSuite(harness: RealtimeTransportHarness): void { + describe(`RealtimeTransport conformance: ${harness.name}`, () => { + it('publish never throws or rejects, even to a channel with no subscribers', async () => { + const transport = harness.create(); + await expect( + Promise.resolve(transport.publish('nobody-listening', { x: 1 })), + ).resolves.toBeUndefined(); + }); + + it('subscribe returns an unsubscribe function that is idempotent', () => { + const transport = harness.create(); + const unsubscribe = transport.subscribe('c', () => {}); + expect(typeof unsubscribe).toBe('function'); + expect(() => unsubscribe()).not.toThrow(); + expect(() => unsubscribe()).not.toThrow(); + }); + + if (harness.supportsServerSideSubscribe) { + it('fans a published event out to every subscriber of the channel', () => { + const transport = harness.create(); + const a: number[] = []; + const b: number[] = []; + transport.subscribe('c', (event) => a.push(event)); + transport.subscribe('c', (event) => b.push(event)); + transport.publish('c', 1); + expect(a).toEqual([1]); + expect(b).toEqual([1]); + }); + + it('does not deliver across channels', () => { + const transport = harness.create(); + const got: number[] = []; + transport.subscribe('one', (event) => got.push(event)); + transport.publish('two', 99); + expect(got).toEqual([]); + }); + + it('stops delivering after unsubscribe', () => { + const transport = harness.create(); + const got: number[] = []; + const unsubscribe = transport.subscribe('c', (event) => got.push(event)); + transport.publish('c', 1); + unsubscribe(); + transport.publish('c', 2); + expect(got).toEqual([1]); + }); + } + + if (harness.addPresence) { + it('getOnlineUserIds excludes anonymous:* connections', async () => { + const transport = harness.create(); + await harness.addPresence?.(transport, 'room', [ + { userId: 'player-1', connectionId: 'tab-1' }, + { userId: 'anonymous:conn-1', connectionId: 'tab-2' }, + ]); + const ids = await transport.getOnlineUserIds('room'); + expect(ids).toEqual(['player-1']); + }); + + it('getOnlineUserIds deduplicates concurrent connections for the same user', async () => { + const transport = harness.create(); + await harness.addPresence?.(transport, 'room', [ + { userId: 'player-1', connectionId: 'tab-1' }, + { userId: 'player-1', connectionId: 'tab-2' }, + ]); + const ids = await transport.getOnlineUserIds('room'); + expect(ids).toEqual(['player-1']); + }); + } + + if (harness.simulateFailure) { + it('getOnlineUserIds degrades to an empty array instead of throwing when unhealthy', async () => { + const transport = harness.create(); + harness.simulateFailure?.(transport); + await expect(transport.getOnlineUserIds('room')).resolves.toEqual([]); + }); + } + }); +} diff --git a/packages/core/src/wallet/drizzle/migrations/0004_reflective_shriek.sql b/packages/core/src/wallet/drizzle/migrations/0004_reflective_shriek.sql new file mode 100644 index 00000000..2e5902d3 --- /dev/null +++ b/packages/core/src/wallet/drizzle/migrations/0004_reflective_shriek.sql @@ -0,0 +1,2 @@ +ALTER TYPE "public"."wallet_transaction_type" ADD VALUE 'gift';--> statement-breakpoint +ALTER TYPE "public"."wallet_transaction_type" ADD VALUE 'rain'; diff --git a/packages/core/src/wallet/drizzle/migrations/meta/0004_snapshot.json b/packages/core/src/wallet/drizzle/migrations/meta/0004_snapshot.json new file mode 100644 index 00000000..7bc61a45 --- /dev/null +++ b/packages/core/src/wallet/drizzle/migrations/meta/0004_snapshot.json @@ -0,0 +1,567 @@ +{ + "id": "004d59f7-7a59-4271-9518-0a856c6b62c7", + "prevId": "dc212a38-53e8-4ed3-9b0b-4e6eb50d9477", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auto_withdrawal_rule": { + "name": "auto_withdrawal_rule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "threshold": { + "name": "threshold", + "type": "numeric(18, 8)", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auto_withdrawal_rule_user_id_unique": { + "name": "auto_withdrawal_rule_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet": { + "name": "wallet", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "numeric(18, 8)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "wallet_user_id_unique": { + "name": "wallet_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_deposit_address": { + "name": "wallet_deposit_address", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_deposit_address_user_id_currency_idx": { + "name": "wallet_deposit_address_user_id_currency_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_deposit_address_address_idx": { + "name": "wallet_deposit_address_address_idx", + "columns": [ + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_transaction": { + "name": "wallet_transaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "wallet_id": { + "name": "wallet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "wallet_transaction_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(18, 8)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "wallet_transaction_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "rail": { + "name": "rail", + "type": "wallet_rail", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "review_reason": { + "name": "review_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_ref_id": { + "name": "provider_ref_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_address": { + "name": "destination_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tx_hash": { + "name": "tx_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_transaction_wallet_id_idx": { + "name": "wallet_transaction_wallet_id_idx", + "columns": [ + { + "expression": "wallet_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_created_at_idx": { + "name": "wallet_transaction_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_type_idx": { + "name": "wallet_transaction_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_status_idx": { + "name": "wallet_transaction_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_rail_idx": { + "name": "wallet_transaction_rail_idx", + "columns": [ + { + "expression": "rail", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_currency_idx": { + "name": "wallet_transaction_currency_idx", + "columns": [ + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_tx_hash_idx": { + "name": "wallet_transaction_tx_hash_idx", + "columns": [ + { + "expression": "tx_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_status_type_created_at_idx": { + "name": "wallet_transaction_status_type_created_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_wallet_id_type_status_idx": { + "name": "wallet_transaction_wallet_id_type_status_idx", + "columns": [ + { + "expression": "wallet_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_provider_ref_id_idx": { + "name": "wallet_transaction_provider_ref_id_idx", + "columns": [ + { + "expression": "provider_ref_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_transaction\".\"provider_ref_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_wallet_id_idempotency_key_idx": { + "name": "wallet_transaction_wallet_id_idempotency_key_idx", + "columns": [ + { + "expression": "wallet_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_transaction\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wallet_transaction_wallet_id_wallet_id_fk": { + "name": "wallet_transaction_wallet_id_wallet_id_fk", + "tableFrom": "wallet_transaction", + "tableTo": "wallet", + "columnsFrom": ["wallet_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.wallet_rail": { + "name": "wallet_rail", + "schema": "public", + "values": ["crypto", "fiat"] + }, + "public.wallet_transaction_status": { + "name": "wallet_transaction_status", + "schema": "public", + "values": ["pending", "processing", "completed", "failed", "rejected", "on_hold", "cancelled"] + }, + "public.wallet_transaction_type": { + "name": "wallet_transaction_type", + "schema": "public", + "values": ["deposit", "withdrawal", "bet", "win", "loss", "bonus", "tip", "gift", "rain"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/core/src/wallet/drizzle/migrations/meta/_journal.json b/packages/core/src/wallet/drizzle/migrations/meta/_journal.json index e114fc02..8ac6267c 100644 --- a/packages/core/src/wallet/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/wallet/drizzle/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1785015620531, "tag": "0003_certain_ogun", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1785142636143, + "tag": "0004_reflective_shriek", + "breakpoints": true } ] } diff --git a/packages/core/src/wallet/service/wallet-commands.service.ts b/packages/core/src/wallet/service/wallet-commands.service.ts index 3b2767ec..87d7610b 100644 --- a/packages/core/src/wallet/service/wallet-commands.service.ts +++ b/packages/core/src/wallet/service/wallet-commands.service.ts @@ -69,7 +69,7 @@ export class WalletCommandsService implements WalletCommands { if (type === 'loss') { await this.writeLedgerRow(txn, row, 'loss', '0'); - return { ok: true, newBalance: available }; + return { ok: true, newBalance: available, currency: row.currency }; } // The UPDATE ... RETURNING gives the new balance straight from Postgres numeric @@ -86,7 +86,7 @@ export class WalletCommandsService implements WalletCommands { await this.writeLedgerRow(txn, row, type, amount); - return { ok: true, newBalance }; + return { ok: true, newBalance, currency: row.currency }; } async credit( diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 5831bbec..629a8810 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -52,14 +52,18 @@ "@openora/core/engagement": ["./src/engagement/index.ts"], "@openora/core/engagement/contracts": ["./src/engagement/contracts.ts"], "@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/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/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/react": ["./src/engagement/react.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/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"], "@openora/core/iam/contract": ["./src/iam/contract/index.ts"], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da50195d..4c1677c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -120,7 +120,7 @@ importers: version: 1.14.5(ws@8.21.1) '@orpc/tanstack-query': specifier: 1.14.5 - version: 1.14.5(@orpc/client@1.14.5)(@tanstack/query-core@5.101.0) + version: 1.14.5(@orpc/client@1.14.5)(@tanstack/query-core@5.101.4) '@orpc/zod': specifier: 1.14.5 version: 1.14.5(@orpc/contract@1.14.5)(@orpc/server@1.14.5(ws@8.21.1))(ws@8.21.1)(zod@4.4.3) @@ -159,8 +159,14 @@ importers: version: 4.4.3 devDependencies: '@tanstack/react-query': - specifier: 5.101.0 - version: 5.101.0(react@19.2.7) + specifier: 5.101.4 + version: 5.101.4(react@19.2.7) + '@testing-library/dom': + specifier: 10.4.1 + version: 10.4.1 + '@testing-library/react': + specifier: 16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@turbo/gen': specifier: 2.9.16 version: 2.9.16(@types/node@25.9.2) @@ -179,12 +185,15 @@ importers: drizzle-kit: specifier: 0.31.10 version: 0.31.10 + jsdom: + specifier: 29.1.1 + version: 29.1.1(@noble/hashes@2.2.0) typescript: specifier: 6.0.3 version: 6.0.3 vitest: specifier: 4.1.8 - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) packages/create-openora: devDependencies: @@ -246,7 +255,7 @@ importers: version: 6.0.3 vitest: specifier: 4.1.8 - version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) + version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) packages: @@ -254,6 +263,21 @@ packages: resolution: {integrity: sha512-hBptHbB6Regs5AgtHiLr/WfgHRh6ZGdA9EexOWU14n2eNjQekvK9/gg18iFUYvL2WrNb4X3Ci0bzgzJXhzmpNQ==} engines: {node: '>=12'} + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -365,6 +389,10 @@ packages: '@borewit/text-codec@0.2.2': resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@changesets/apply-release-plan@7.1.1': resolution: {integrity: sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==} @@ -509,6 +537,42 @@ packages: resolution: {integrity: sha512-TzlTVpKPjaqW6qOYjQcYUDuGsLCNsvFHVBXkYGTAnf5V37jCWrE5haKNXzz0WZUtVHjrpV76L1buANjwXMfT8w==} engines: {node: '>=22'} + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.10': + resolution: {integrity: sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.7': + resolution: {integrity: sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -685,6 +749,15 @@ packages: cpu: [x64] os: [win32] + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@hono/node-server@1.19.14': resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} @@ -1681,14 +1754,33 @@ packages: '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} - '@tanstack/query-core@5.101.0': - resolution: {integrity: sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==} + '@tanstack/query-core@5.101.4': + resolution: {integrity: sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==} - '@tanstack/react-query@5.101.0': - resolution: {integrity: sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==} + '@tanstack/react-query@5.101.4': + resolution: {integrity: sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==} peerDependencies: react: 19.2.7 + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: 19.2.7 + react-dom: 19.2.7 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@tokenizer/inflate@0.4.1': resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} engines: {node: '>=18'} @@ -1743,6 +1835,9 @@ packages: '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -1880,6 +1975,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -1894,6 +1993,9 @@ packages: resolution: {integrity: sha512-DhBpBfXL4SS2uC0N922MMajKR3CdrTG0u2or1PNYgXMsrSzViJrbtvT0nCLlLGUI0plam/ZZCs7aAauHtW9thw==} engines: {node: '>=22'} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + array-union@2.1.0: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} @@ -2001,6 +2103,9 @@ packages: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -2173,9 +2278,17 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -2185,6 +2298,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-equal@1.0.1: resolution: {integrity: sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==} @@ -2220,6 +2336,10 @@ packages: engines: {bun: '>=1.1', node: '>=18'} hasBin: true + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + destroy@1.2.0: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -2236,6 +2356,9 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dotenv@17.4.2: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} @@ -2371,6 +2494,10 @@ packages: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -2638,6 +2765,10 @@ packages: resolution: {integrity: sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==} engines: {node: '>=16.9.0'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -2762,6 +2893,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -2825,6 +2959,15 @@ packages: resolution: {integrity: sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==} hasBin: true + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} @@ -2969,10 +3112,18 @@ packages: lodash.startcase@4.4.0: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + luxon@3.7.2: resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} engines: {node: '>=12'} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -2991,6 +3142,9 @@ packages: resolution: {integrity: sha512-N/V131Y269ns9oM2/AUMnjAcghsMEENYt0E3B+Ehc5uRVMpBX3p4tsncvQIkN2EzTE5feoQetN+qQq/4HrCa0g==} hasBin: true + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} @@ -3187,6 +3341,9 @@ packages: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -3335,6 +3492,10 @@ packages: engines: {node: '>=10.13.0'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-ms@9.3.0: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} @@ -3394,6 +3555,9 @@ packages: peerDependencies: react: 19.2.7 + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react@19.2.7: resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} engines: {node: '>=0.10.0'} @@ -3483,6 +3647,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -3664,6 +3832,9 @@ packages: rescript: optional: true + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -3703,6 +3874,13 @@ packages: resolution: {integrity: sha512-EORDwFMSZKrHPUVDhejCMDeAovRS5d8jZKiqALFiPp3cjKjEldPkxBY39ZSx3c45awz3RpKwJD1cCgGxEfy8/A==} engines: {node: '>= 20'} + tldts-core@7.4.9: + resolution: {integrity: sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==} + + tldts@7.4.9: + resolution: {integrity: sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -3715,6 +3893,14 @@ packages: resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} engines: {node: '>=14.16'} + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + ts-morph@28.0.0: resolution: {integrity: sha512-Wp3tnZ2bzwxyTZMtgWVzXDfm7lB1Drz+y9DmmYH/L702PQhPyVrp3pkou3yIz4qjS14GY9kcpmLiOOMvl8oG1g==} @@ -3887,11 +4073,27 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + watskeburt@6.0.0: resolution: {integrity: sha512-jfiuDABaxSkC71T6oZ3vCS99roYkSHm/+As+G0Dz8taAHQb+SJBvLEm5RlsgG71XdfAj3rv7eudUBTgwcQUPlQ==} engines: {node: ^22.13||^24||>=26} hasBin: true + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -3928,6 +4130,13 @@ packages: utf-8-validate: optional: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xsschema@0.4.4: resolution: {integrity: sha512-TMhbk8AhxO+YuDOn3rXBjGXQ+Vja3T13UPQkHx/FemhusaOzRfCSj2seykt0mdnFPsOtO9l3ANf4adcp+4NRGw==} peerDependencies: @@ -3987,6 +4196,26 @@ snapshots: '@2toad/profanity@3.3.0': {} + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -4069,6 +4298,10 @@ snapshots: '@borewit/text-codec@0.2.2': {} + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@changesets/apply-release-plan@7.1.1': dependencies: '@changesets/config': 3.1.4 @@ -4334,6 +4567,30 @@ snapshots: '@conventional-changelog/template@1.2.1': {} + '@csstools/color-helpers@6.1.0': {} + + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@drizzle-team/brocli@0.10.2': {} '@emnapi/core@1.10.0': @@ -4440,6 +4697,10 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@exodus/bytes@1.15.1(@noble/hashes@2.2.0)': + optionalDependencies: + '@noble/hashes': 2.2.0 + '@hono/node-server@1.19.14(hono@4.12.25)': dependencies: hono: 4.12.25 @@ -5147,11 +5408,11 @@ snapshots: transitivePeerDependencies: - '@opentelemetry/api' - '@orpc/tanstack-query@1.14.5(@orpc/client@1.14.5)(@tanstack/query-core@5.101.0)': + '@orpc/tanstack-query@1.14.5(@orpc/client@1.14.5)(@tanstack/query-core@5.101.4)': dependencies: '@orpc/client': 1.14.5 '@orpc/shared': 1.14.5 - '@tanstack/query-core': 5.101.0 + '@tanstack/query-core': 5.101.4 transitivePeerDependencies: - '@opentelemetry/api' @@ -5377,13 +5638,34 @@ snapshots: tslib: 2.8.1 optional: true - '@tanstack/query-core@5.101.0': {} + '@tanstack/query-core@5.101.4': {} - '@tanstack/react-query@5.101.0(react@19.2.7)': + '@tanstack/react-query@5.101.4(react@19.2.7)': dependencies: - '@tanstack/query-core': 5.101.0 + '@tanstack/query-core': 5.101.4 react: 19.2.7 + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@tokenizer/inflate@0.4.1': dependencies: debug: 4.4.3 @@ -5438,6 +5720,8 @@ snapshots: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -5487,7 +5771,7 @@ snapshots: obug: 2.1.2 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) + vitest: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) optional: true '@vitest/expect@4.1.8': @@ -5586,6 +5870,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} argparse@1.0.10: @@ -5596,6 +5882,10 @@ snapshots: argue-cli@3.1.0: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + array-union@2.1.0: {} assertion-error@2.0.1: {} @@ -5654,7 +5944,7 @@ snapshots: pg: 8.21.0 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - vitest: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) + vitest: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) transitivePeerDependencies: - '@cloudflare/workers-types' - '@opentelemetry/api' @@ -5672,6 +5962,10 @@ snapshots: dependencies: is-windows: 1.0.2 + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -5835,12 +6129,26 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + csstype@3.2.3: {} + data-urls@7.0.0(@noble/hashes@2.2.0): + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@2.2.0) + transitivePeerDependencies: + - '@noble/hashes' + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + deep-equal@1.0.1: {} defu@6.1.7: {} @@ -5883,6 +6191,8 @@ snapshots: picocolors: 1.1.1 ts-morph: 28.0.0 + dequal@2.0.3: {} + destroy@1.2.0: {} detect-indent@6.1.0: {} @@ -5893,6 +6203,8 @@ snapshots: dependencies: path-type: 4.0.0 + dom-accessibility-api@0.5.16: {} + dotenv@17.4.2: {} drizzle-kit@0.31.10: @@ -5946,6 +6258,8 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 + entities@8.0.0: {} + env-paths@2.2.1: {} error-ex@1.3.4: @@ -6289,6 +6603,12 @@ snapshots: hono@4.12.25: {} + html-encoding-sniffer@6.0.0(@noble/hashes@2.2.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) + transitivePeerDependencies: + - '@noble/hashes' + html-escaper@2.0.2: optional: true @@ -6400,6 +6720,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} is-stream@4.0.1: {} @@ -6455,6 +6777,32 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@29.1.1(@noble/hashes@2.2.0): + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.7(css-tree@3.2.1) + '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) + css-tree: 3.2.1 + data-urls: 7.0.0(@noble/hashes@2.2.0) + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@2.2.0) + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 7.28.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@2.2.0) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + json-parse-even-better-errors@2.3.1: {} json-schema-traverse@1.0.0: {} @@ -6576,8 +6924,12 @@ snapshots: lodash.startcase@4.4.0: {} + lru-cache@11.5.2: {} + luxon@3.7.2: {} + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -6602,6 +6954,8 @@ snapshots: transitivePeerDependencies: - supports-color + mdn-data@2.27.1: {} + media-typer@1.1.0: {} merge-descriptors@2.0.0: {} @@ -6797,6 +7151,10 @@ snapshots: parse-ms@4.0.0: {} + parse5@8.0.1: + dependencies: + entities: 8.0.0 + parseurl@1.3.3: {} path-browserify@1.0.1: {} @@ -6938,6 +7296,12 @@ snapshots: prettier@2.8.8: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + pretty-ms@9.3.0: dependencies: parse-ms: 4.0.0 @@ -6991,6 +7355,8 @@ snapshots: react: 19.2.7 scheduler: 0.27.0 + react-is@17.0.2: {} + react@19.2.7: {} read-yaml-file@1.1.0: @@ -7115,6 +7481,10 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} section-matter@1.0.0: @@ -7309,6 +7679,8 @@ snapshots: sury@10.0.4: {} + symbol-tree@3.2.4: {} + tagged-tag@1.0.0: {} tapable@2.3.3: {} @@ -7336,6 +7708,12 @@ snapshots: dependencies: punycode: 2.3.1 + tldts-core@7.4.9: {} + + tldts@7.4.9: + dependencies: + tldts-core: 7.4.9 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -7348,6 +7726,14 @@ snapshots: '@tokenizer/token': 0.3.0 ieee754: 1.2.1 + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.9 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + ts-morph@28.0.0: dependencies: '@ts-morph/common': 0.29.0 @@ -7437,7 +7823,7 @@ snapshots: jiti: 2.7.0 tsx: 4.23.1 - vitest@4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)): + vitest@4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)): dependencies: '@vitest/expect': 4.1.8 '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) @@ -7462,11 +7848,28 @@ snapshots: optionalDependencies: '@types/node': 25.9.2 '@vitest/coverage-v8': 4.1.8(vitest@4.1.8) + jsdom: 29.1.1(@noble/hashes@2.2.0) transitivePeerDependencies: - msw + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + watskeburt@6.0.0: {} + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1(@noble/hashes@2.2.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which@2.0.2: dependencies: isexe: 2.0.0 @@ -7495,6 +7898,10 @@ snapshots: ws@8.21.1: optional: true + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + xsschema@0.4.4(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@6.0.3)))(effect@3.22.0)(sury@10.0.4)(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3): optionalDependencies: '@valibot/to-json-schema': 1.7.1(valibot@1.4.1(typescript@6.0.3)) diff --git a/tools/db/seed.ts b/tools/db/seed.ts index cad7c5dc..91ce579c 100644 --- a/tools/db/seed.ts +++ b/tools/db/seed.ts @@ -21,6 +21,7 @@ import { createAuth, createDrizzleDb } from '@openora/core/server'; import { seedIam } from '@openora/core/iam/seed'; import { seedTag } from '@openora/core/pam/tag/seed'; +import { seedChatCommands } from '@openora/core/engagement/seed/chat-commands'; import { seedDemoData } from '@openora/testing'; import { user, session, account, verification, twoFactor } from '@openora/core/pam/schema/identity'; @@ -43,6 +44,8 @@ async function main() { console.log(' Reference roles ready.'); await seedTag(db); console.log(' Default tags ready.'); + await seedChatCommands(db); + console.log(' Chat command config ready.'); const result = await seedDemoData({ db, diff --git a/tools/gen/build-contract.ts b/tools/gen/build-contract.ts index e1dc9166..b9f74419 100644 --- a/tools/gen/build-contract.ts +++ b/tools/gen/build-contract.ts @@ -13,6 +13,7 @@ import { profileContract } from '@openora/core/pam/contracts/profile'; import { cmsContract } from '@openora/core/cms/contracts'; import { notificationsContract } from '@openora/core/engagement/contracts/notifications'; import { chatContract } from '@openora/core/engagement/contracts/chat'; +import { chatCommandsContract } from '@openora/core/engagement/contracts/chat-commands'; import { walletContract } from '@openora/core/wallet/contract'; import { gamingContract } from '@openora/core/casino/contracts/gaming'; import { lobbyContract } from '@openora/core/casino/contracts/lobby'; @@ -36,6 +37,7 @@ const SLICES: Record = { wallet: walletContract, gaming: gamingContract, chat: chatContract, + 'chat-commands': chatCommandsContract, lobby: lobbyContract, backoffice: backofficeContract, analytics: analyticsContract,