From ff325b203f830988400da9a56c87e85c63fc6d93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oleksandr=20Kapitu=C5=82a?= Date: Mon, 5 Oct 2026 12:03:32 +0200 Subject: [PATCH 1/4] feat(chat): backoffice list of the restrictions reaching a room adminListRoomRestrictions returns the active mutes, bans and cooldowns that reach one room, paginated in SQL and newest first, with who set each entry. Wide-scope rows count when platformScopesFor(roomReach(room)) covers them; room-moderator mutes and bans report source 'room'. - GET /backoffice/chat/rooms/{roomId}/restrictions, guarded by chat-moderation:view, 404 for an unknown or deleted room - one UNION ALL over the five restriction tables plus a count - partial (room_id, expires_at) and (scope, expires_at) indexes on active chat_mute, chat_platform_ban and chat_player_cooldown rows --- .../src/engagement/chat/contract/constants.ts | 4 + .../src/engagement/chat/contract/index.ts | 29 + .../0018_chat_restriction_room_indexes.sql | 6 + .../migrations/meta/0018_snapshot.json | 1905 +++++++++++++++++ .../drizzle/migrations/meta/_journal.json | 7 + packages/core/src/engagement/chat/plugin.ts | 5 + .../core/src/engagement/chat/router/index.ts | 10 + .../core/src/engagement/chat/schema/index.ts | 18 + .../service/chat-room-restriction.service.ts | 213 ++ .../chat-room-restrictions.e2e.test.ts | 381 ++++ 10 files changed, 2578 insertions(+) create mode 100644 packages/core/src/engagement/chat/drizzle/migrations/0018_chat_restriction_room_indexes.sql create mode 100644 packages/core/src/engagement/chat/drizzle/migrations/meta/0018_snapshot.json create mode 100644 packages/core/src/engagement/chat/service/chat-room-restriction.service.ts create mode 100644 packages/testing/src/__tests__/chat-room-restrictions.e2e.test.ts diff --git a/packages/core/src/engagement/chat/contract/constants.ts b/packages/core/src/engagement/chat/contract/constants.ts index 4c1417d7..bee60fed 100644 --- a/packages/core/src/engagement/chat/contract/constants.ts +++ b/packages/core/src/engagement/chat/contract/constants.ts @@ -21,6 +21,10 @@ export const ROOM_ACTIVITY_WINDOW_HOURS_MAX = 168; export const CHAT_ROOM_ROLES = ['member', 'moderator', 'owner'] as const; +export const CHAT_ROOM_RESTRICTION_TYPES = ['mute', 'ban', 'cooldown'] as const; +// `admin`: set by staff through the backoffice. `room`: set by the room's owner or a moderator. +export const CHAT_ROOM_RESTRICTION_SOURCES = ['admin', 'room'] as const; + // Roles a room owner can grant or revoke through the member-role route. `owner` is absent // on purpose: ownership moves through its own transfer flow, never through a role write. export const CHAT_ROOM_ASSIGNABLE_ROLES = ['member', 'moderator'] as const; diff --git a/packages/core/src/engagement/chat/contract/index.ts b/packages/core/src/engagement/chat/contract/index.ts index 910935b5..0b723f1b 100644 --- a/packages/core/src/engagement/chat/contract/index.ts +++ b/packages/core/src/engagement/chat/contract/index.ts @@ -35,6 +35,8 @@ import { CHAT_ROOM_ASSIGNABLE_ROLES, ROOM_ACTIVITY_WINDOW_HOURS_DEFAULT, ROOM_ACTIVITY_WINDOW_HOURS_MAX, + CHAT_ROOM_RESTRICTION_TYPES, + CHAT_ROOM_RESTRICTION_SOURCES, } from './constants.js'; export * from './constants.js'; @@ -294,6 +296,22 @@ export const ChatPlatformBanSchema = z.object({ }); const ModerationReasonSchema = z.string().trim().min(1).max(CHAT_MODERATION_REASON_MAX_LENGTH); +export const ChatRoomRestrictionTypeSchema = z.enum(CHAT_ROOM_RESTRICTION_TYPES); +export type ChatRoomRestrictionType = z.infer; +export const ChatRoomRestrictionSourceSchema = z.enum(CHAT_ROOM_RESTRICTION_SOURCES); +export type ChatRoomRestrictionSource = z.infer; +// A room-moderator restriction reports scope `room` with its room id. +export const ChatRoomRestrictionSchema = ChatModerationEntrySchema.extend({ + type: ChatRoomRestrictionTypeSchema, + source: ChatRoomRestrictionSourceSchema, + username: z.string().nullable(), + // Null for a room-moderator ban, which records no reason. + reason: z.string().nullable(), + setBy: z.object({ id: UuidSchema, name: z.string().nullable() }), + cooldownSeconds: z.number().int().positive().nullable(), +}); +export type ChatRoomRestriction = z.infer; + const AdminModerationInput = z.object({ userId: UuidSchema, reason: ModerationReasonSchema, @@ -318,6 +336,11 @@ export const AdminLiftChatCooldownInputSchema = z.object({ const RoomIdInput = z.object({ roomId: UuidSchema }); const RoomRulesInput = z.object({ roomId: UuidSchema.or(z.literal(GLOBAL_CHAT_ROOM_ID)) }); +const ListRoomRestrictionsInputSchema = RoomRulesInput.extend({ + type: ChatRoomRestrictionTypeSchema.optional(), + ...PageQuerySchema.shape, +}); +export type ListRoomRestrictionsInput = z.infer; const RoomUserInput = z.object({ roomId: UuidSchema, userId: UuidSchema }); const RoomModerationInput = RoomUserInput.extend({ reason: ModerationReasonSchema.default(''), @@ -657,6 +680,12 @@ export const chatContract = { ) .output(ChatRoomSchema), + // Active restrictions set in the room plus the platform-wide scopes that cover its kind. + adminListRoomRestrictions: oc + .route({ method: 'GET', path: '/backoffice/chat/rooms/{roomId}/restrictions' }) + .input(ListRoomRestrictionsInputSchema) + .output(paginated(ChatRoomRestrictionSchema)), + adminCreateRoomRule: oc .route({ method: 'POST', path: '/backoffice/chat/rooms/{roomId}/rules' }) .input(CreateRoomRuleInputSchema) diff --git a/packages/core/src/engagement/chat/drizzle/migrations/0018_chat_restriction_room_indexes.sql b/packages/core/src/engagement/chat/drizzle/migrations/0018_chat_restriction_room_indexes.sql new file mode 100644 index 00000000..68d8631b --- /dev/null +++ b/packages/core/src/engagement/chat/drizzle/migrations/0018_chat_restriction_room_indexes.sql @@ -0,0 +1,6 @@ +CREATE INDEX "chat_mute_active_room_idx" ON "chat_mute" USING btree ("room_id","expires_at") WHERE "chat_mute"."lifted_at" IS NULL;--> statement-breakpoint +CREATE INDEX "chat_mute_active_scope_idx" ON "chat_mute" USING btree ("scope","expires_at") WHERE "chat_mute"."lifted_at" IS NULL;--> statement-breakpoint +CREATE INDEX "chat_platform_ban_active_room_idx" ON "chat_platform_ban" USING btree ("room_id","expires_at") WHERE "chat_platform_ban"."lifted_at" IS NULL;--> statement-breakpoint +CREATE INDEX "chat_platform_ban_active_scope_idx" ON "chat_platform_ban" USING btree ("scope","expires_at") WHERE "chat_platform_ban"."lifted_at" IS NULL;--> statement-breakpoint +CREATE INDEX "chat_player_cooldown_active_room_idx" ON "chat_player_cooldown" USING btree ("room_id","expires_at") WHERE "chat_player_cooldown"."lifted_at" IS NULL;--> statement-breakpoint +CREATE INDEX "chat_player_cooldown_active_scope_idx" ON "chat_player_cooldown" USING btree ("scope","expires_at") WHERE "chat_player_cooldown"."lifted_at" IS NULL; \ No newline at end of file diff --git a/packages/core/src/engagement/chat/drizzle/migrations/meta/0018_snapshot.json b/packages/core/src/engagement/chat/drizzle/migrations/meta/0018_snapshot.json new file mode 100644 index 00000000..caebc699 --- /dev/null +++ b/packages/core/src/engagement/chat/drizzle/migrations/meta/0018_snapshot.json @@ -0,0 +1,1905 @@ +{ + "id": "5096c9ce-5a80-452f-9e28-dede08507106", + "prevId": "b1f974c6-596c-4cce-9f22-c3310eb3db59", + "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 + }, + "attachment": { + "name": "attachment", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_deleted": { + "name": "is_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_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": { + "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": {} + }, + "chat_msg_deleted_at_idx": { + "name": "chat_msg_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_msg_user_id_created_at_idx": { + "name": "chat_msg_user_id_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "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": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_mute": { + "name": "chat_mute", + "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 + }, + "room_id": { + "name": "room_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "chat_moderation_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'__global'" + }, + "muted_by": { + "name": "muted_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lifted_at": { + "name": "lifted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lifted_by": { + "name": "lifted_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "expiry_recorded_at": { + "name": "expiry_recorded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chat_mute_active_scope_key": { + "name": "chat_mute_active_scope_key", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat_mute\".\"lifted_at\" IS NULL AND \"chat_mute\".\"room_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_mute_active_room_key": { + "name": "chat_mute_active_room_key", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat_mute\".\"lifted_at\" IS NULL AND \"chat_mute\".\"room_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_mute_user_room_idx": { + "name": "chat_mute_user_room_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_mute_expires_at_idx": { + "name": "chat_mute_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_mute_active_room_idx": { + "name": "chat_mute_active_room_idx", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat_mute\".\"lifted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_mute_active_scope_idx": { + "name": "chat_mute_active_scope_idx", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat_mute\".\"lifted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_platform_ban": { + "name": "chat_platform_ban", + "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 + }, + "banned_by": { + "name": "banned_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "chat_moderation_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'__all_public'" + }, + "room_id": { + "name": "room_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lifted_at": { + "name": "lifted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lifted_by": { + "name": "lifted_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "expiry_recorded_at": { + "name": "expiry_recorded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chat_platform_ban_active_scope_key": { + "name": "chat_platform_ban_active_scope_key", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat_platform_ban\".\"lifted_at\" IS NULL AND \"chat_platform_ban\".\"room_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_platform_ban_active_room_key": { + "name": "chat_platform_ban_active_room_key", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat_platform_ban\".\"lifted_at\" IS NULL AND \"chat_platform_ban\".\"room_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_platform_ban_user_idx": { + "name": "chat_platform_ban_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_platform_ban_expires_at_idx": { + "name": "chat_platform_ban_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_platform_ban_active_room_idx": { + "name": "chat_platform_ban_active_room_idx", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat_platform_ban\".\"lifted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_platform_ban_active_scope_idx": { + "name": "chat_platform_ban_active_scope_idx", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat_platform_ban\".\"lifted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_platform_ban_room_id_chat_room_id_fk": { + "name": "chat_platform_ban_room_id_chat_room_id_fk", + "tableFrom": "chat_platform_ban", + "tableTo": "chat_room", + "columnsFrom": ["room_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_player_cooldown": { + "name": "chat_player_cooldown", + "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 + }, + "room_id": { + "name": "room_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "chat_moderation_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "cooldown_seconds": { + "name": "cooldown_seconds", + "type": "integer", + "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()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lifted_at": { + "name": "lifted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lifted_by": { + "name": "lifted_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "expiry_recorded_at": { + "name": "expiry_recorded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chat_player_cooldown_active_scope_key": { + "name": "chat_player_cooldown_active_scope_key", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat_player_cooldown\".\"lifted_at\" IS NULL AND \"chat_player_cooldown\".\"room_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_player_cooldown_active_room_key": { + "name": "chat_player_cooldown_active_room_key", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat_player_cooldown\".\"lifted_at\" IS NULL AND \"chat_player_cooldown\".\"room_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_player_cooldown_user_idx": { + "name": "chat_player_cooldown_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_player_cooldown_active_room_idx": { + "name": "chat_player_cooldown_active_room_idx", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat_player_cooldown\".\"lifted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_player_cooldown_active_scope_idx": { + "name": "chat_player_cooldown_active_scope_idx", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat_player_cooldown\".\"lifted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_player_cooldown_expiry_due_idx": { + "name": "chat_player_cooldown_expiry_due_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat_player_cooldown\".\"expires_at\" IS NOT NULL AND \"chat_player_cooldown\".\"expiry_recorded_at\" IS NULL AND (\"chat_player_cooldown\".\"lifted_at\" IS NULL OR \"chat_player_cooldown\".\"lifted_at\" = \"chat_player_cooldown\".\"expires_at\")", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "chat_player_cooldown_seconds_check": { + "name": "chat_player_cooldown_seconds_check", + "value": "\"chat_player_cooldown\".\"cooldown_seconds\" > 0 AND \"chat_player_cooldown\".\"cooldown_seconds\" <= 86400" + }, + "chat_player_cooldown_room_scope_check": { + "name": "chat_player_cooldown_room_scope_check", + "value": "(\"chat_player_cooldown\".\"scope\" = 'room') = (\"chat_player_cooldown\".\"room_id\" IS NOT NULL)" + } + }, + "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": false + }, + "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 + }, + "scheduled_deletion_at": { + "name": "scheduled_deletion_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": {} + }, + "chat_room_scheduled_deletion_idx": { + "name": "chat_room_scheduled_deletion_idx", + "columns": [ + { + "expression": "scheduled_deletion_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat_room\".\"scheduled_deletion_at\" IS NOT NULL", + "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()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lifted_at": { + "name": "lifted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lifted_by": { + "name": "lifted_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chat_room_ban_active_room_user_key": { + "name": "chat_room_ban_active_room_user_key", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat_room_ban\".\"lifted_at\" IS NULL", + "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": {} + }, + "chat_room_ban_user_idx": { + "name": "chat_room_ban_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_room_ban_expires_at_idx": { + "name": "chat_room_ban_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "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_configuration": { + "name": "chat_room_configuration", + "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 + }, + "slow_mode": { + "name": "slow_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "slow_mode_seconds": { + "name": "slow_mode_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "read_only_mode": { + "name": "read_only_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "only_invited_can_join": { + "name": "only_invited_can_join", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "lock_room": { + "name": "lock_room", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "moderator_invite": { + "name": "moderator_invite", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "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, + "default": "now()" + } + }, + "indexes": { + "chat_room_configuration_room_key": { + "name": "chat_room_configuration_room_key", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_room_configuration_room_id_chat_room_id_fk": { + "name": "chat_room_configuration_room_id_chat_room_id_fk", + "tableFrom": "chat_room_configuration", + "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'" + }, + "role_assigned_at": { + "name": "role_assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "account_closed_at": { + "name": "account_closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "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_room_mute": { + "name": "chat_room_mute", + "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 + }, + "muted_by": { + "name": "muted_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lifted_at": { + "name": "lifted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lifted_by": { + "name": "lifted_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chat_room_mute_active_room_user_key": { + "name": "chat_room_mute_active_room_user_key", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat_room_mute\".\"lifted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_room_mute_room_user_idx": { + "name": "chat_room_mute_room_user_idx", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_room_mute_expires_at_idx": { + "name": "chat_room_mute_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_room_mute_room_id_chat_room_id_fk": { + "name": "chat_room_mute_room_id_chat_room_id_fk", + "tableFrom": "chat_room_mute", + "tableTo": "chat_room", + "columnsFrom": ["room_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_room_remove": { + "name": "chat_room_remove", + "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 + }, + "removed_by": { + "name": "removed_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_room_remove_room_created_at_idx": { + "name": "chat_room_remove_room_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_room_remove_user_idx": { + "name": "chat_room_remove_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_room_remove_room_id_chat_room_id_fk": { + "name": "chat_room_remove_room_id_chat_room_id_fk", + "tableFrom": "chat_room_remove", + "tableTo": "chat_room", + "columnsFrom": ["room_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_room_rule": { + "name": "chat_room_rule", + "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 + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "order_num": { + "name": "order_num", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "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, + "default": "now()" + } + }, + "indexes": { + "chat_room_rule_room_order_idx": { + "name": "chat_room_rule_room_order_idx", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_num", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_room_rule_room_idx": { + "name": "chat_room_rule_room_idx", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_room_rule_room_id_chat_room_id_fk": { + "name": "chat_room_rule_room_id_chat_room_id_fk", + "tableFrom": "chat_room_rule", + "tableTo": "chat_room", + "columnsFrom": ["room_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_user_block": { + "name": "chat_user_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "blocker_id": { + "name": "blocker_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "blocked_id": { + "name": "blocked_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chat_user_block_pair_key": { + "name": "chat_user_block_pair_key", + "columns": [ + { + "expression": "blocker_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "blocked_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat_user_block\".\"removed_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_user_block_blocker_idx": { + "name": "chat_user_block_blocker_idx", + "columns": [ + { + "expression": "blocker_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_user_ignore": { + "name": "chat_user_ignore", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ignorer_id": { + "name": "ignorer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ignored_id": { + "name": "ignored_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chat_user_ignore_pair_key": { + "name": "chat_user_ignore_pair_key", + "columns": [ + { + "expression": "ignorer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ignored_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat_user_ignore\".\"removed_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_user_ignore_ignorer_idx": { + "name": "chat_user_ignore_ignorer_idx", + "columns": [ + { + "expression": "ignorer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.chat_message_type": { + "name": "chat_message_type", + "schema": "public", + "values": ["user", "system"] + }, + "public.chat_moderation_scope": { + "name": "chat_moderation_scope", + "schema": "public", + "values": ["__global", "__all_public", "__all", "room"] + }, + "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", "owner"] + } + }, + "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 d7c9c7e9..4fbd1321 100644 --- a/packages/core/src/engagement/chat/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/engagement/chat/drizzle/migrations/meta/_journal.json @@ -127,6 +127,13 @@ "when": 1791375466463, "tag": "0017_chat_player_cooldown", "breakpoints": true + }, + { + "idx": 18, + "version": "7", + "when": 1791375545203, + "tag": "0018_chat_restriction_room_indexes", + "breakpoints": true } ] } diff --git a/packages/core/src/engagement/chat/plugin.ts b/packages/core/src/engagement/chat/plugin.ts index 4e851900..f1b972c8 100644 --- a/packages/core/src/engagement/chat/plugin.ts +++ b/packages/core/src/engagement/chat/plugin.ts @@ -32,6 +32,7 @@ import { import { ChatRoomMembershipService } from './service/chat-room-membership.service.js'; import { ChatRoomBanService } from './service/chat-room-ban.service.js'; import { ChatRoomMuteService } from './service/chat-room-mute.service.js'; +import { ChatRoomRestrictionService } from './service/chat-room-restriction.service.js'; import { ChatModerationExpiryService } from './service/chat-moderation-expiry.service.js'; import { ChatRoomPurgeService } from './service/chat-room-purge.service.js'; import { createChatRouter } from './router/index.js'; @@ -328,6 +329,10 @@ export default { c.get(IDENTITY_READER), ), roomMuteService: new ChatRoomMuteService(c.get(DRIZZLE), c.get(AUDIT_WRITER)), + restrictionService: new ChatRoomRestrictionService( + c.get(DRIZZLE), + c.get(ADMIN_USER_DIRECTORY), + ), moderationService: c.get(CHAT_MODERATION), authorizer: c.get(CHAT_REALTIME_CLIENT_AUTHORIZER), adminGuard: c.get(ADMIN_GUARD), diff --git a/packages/core/src/engagement/chat/router/index.ts b/packages/core/src/engagement/chat/router/index.ts index bce96e7c..61d5883e 100644 --- a/packages/core/src/engagement/chat/router/index.ts +++ b/packages/core/src/engagement/chat/router/index.ts @@ -55,6 +55,7 @@ import { import { ChatRoomMembershipService } from '../service/chat-room-membership.service.js'; import { ChatRoomBanService } from '../service/chat-room-ban.service.js'; import { ChatRoomMuteService } from '../service/chat-room-mute.service.js'; +import { ChatRoomRestrictionService } from '../service/chat-room-restriction.service.js'; const chat = populateContractRouterPaths({ chat: chatContract }).chat; const JOIN_ROOM_RATE_LIMIT = { @@ -102,6 +103,7 @@ export function createChatRouter({ membershipService, roomBanService, roomMuteService, + restrictionService, moderationService, authorizer, adminGuard, @@ -111,6 +113,7 @@ export function createChatRouter({ membershipService: ChatRoomMembershipService; roomBanService: ChatRoomBanService; roomMuteService: ChatRoomMuteService; + restrictionService: ChatRoomRestrictionService; moderationService: ChatModeration; authorizer: RealtimeClientAuthorizer; adminGuard: AdminGuard; @@ -639,6 +642,13 @@ export function createChatRouter({ return chatService.listAdminRooms(input); }), + adminListRoomRestrictions: os.adminListRoomRestrictions.handler(async ({ input, context }) => { + await adminGuard.assert(context, 'chat-moderation', 'view'); + return mapErrors({ NOT_FOUND: ChatRoomNotFoundError }, () => + restrictionService.listRoomRestrictions(input), + ); + }), + adminCreateRoomRule: os.adminCreateRoomRule.handler(async ({ input, context }) => { const { userId, ip, userAgent } = await adminGuard.assert(context, 'chat-room', 'update'); return mapErrors({ NOT_FOUND: ChatRoomNotFoundError }, () => diff --git a/packages/core/src/engagement/chat/schema/index.ts b/packages/core/src/engagement/chat/schema/index.ts index 40090e1d..dd767468 100644 --- a/packages/core/src/engagement/chat/schema/index.ts +++ b/packages/core/src/engagement/chat/schema/index.ts @@ -273,6 +273,12 @@ export const chatPlatformBan = pgTable( .where(sql`${t.liftedAt} IS NULL AND ${t.roomId} IS NOT NULL`), index('chat_platform_ban_user_idx').on(t.userId), index('chat_platform_ban_expires_at_idx').on(t.expiresAt), + index('chat_platform_ban_active_room_idx') + .on(t.roomId, t.expiresAt) + .where(sql`${t.liftedAt} IS NULL`), + index('chat_platform_ban_active_scope_idx') + .on(t.scope, t.expiresAt) + .where(sql`${t.liftedAt} IS NULL`), ], ); @@ -300,6 +306,12 @@ export const chatMute = pgTable( .where(sql`${t.liftedAt} IS NULL AND ${t.roomId} IS NOT NULL`), index('chat_mute_user_room_idx').on(t.userId, t.roomId), index('chat_mute_expires_at_idx').on(t.expiresAt), + index('chat_mute_active_room_idx') + .on(t.roomId, t.expiresAt) + .where(sql`${t.liftedAt} IS NULL`), + index('chat_mute_active_scope_idx') + .on(t.scope, t.expiresAt) + .where(sql`${t.liftedAt} IS NULL`), ], ); @@ -327,6 +339,12 @@ export const chatPlayerCooldown = pgTable( .on(t.userId, t.scope, t.roomId) .where(sql`${t.liftedAt} IS NULL AND ${t.roomId} IS NOT NULL`), index('chat_player_cooldown_user_idx').on(t.userId), + index('chat_player_cooldown_active_room_idx') + .on(t.roomId, t.expiresAt) + .where(sql`${t.liftedAt} IS NULL`), + index('chat_player_cooldown_active_scope_idx') + .on(t.scope, t.expiresAt) + .where(sql`${t.liftedAt} IS NULL`), // Matches the sweep's scan, so a row lifted before its expiry leaves the index. index('chat_player_cooldown_expiry_due_idx') .on(t.expiresAt) diff --git a/packages/core/src/engagement/chat/service/chat-room-restriction.service.ts b/packages/core/src/engagement/chat/service/chat-room-restriction.service.ts new file mode 100644 index 00000000..18a15d20 --- /dev/null +++ b/packages/core/src/engagement/chat/service/chat-room-restriction.service.ts @@ -0,0 +1,213 @@ +import { and, count, desc, eq, gt, inArray, isNull, or, sql, SQL, type Column } from 'drizzle-orm'; +import { unionAll } from 'drizzle-orm/pg-core'; +import { DrizzleService, pageToOffset } from '@openora/core/server'; +import { + GLOBAL_CHAT_ROOM_ID, + type AdminUserDirectory, + type ChatModerationScope, + type Uuid, +} from '@openora/core/contracts'; +import { + chatMute, + chatPlatformBan, + chatPlayerCooldown, + chatRoom, + chatRoomBan, + chatRoomMute, +} from '../schema/index.js'; +import type { + ChatRoomRestriction, + ChatRoomRestrictionSource, + ChatRoomRestrictionType, + ListRoomRestrictionsInput, +} from '../contract/index.js'; +import { platformScopesFor, roomReach } from '../moderation/index.js'; +import { ChatRoomNotFoundError } from './errors/chat-moderation.errors.js'; + +type RestrictionTable = + | typeof chatMute + | typeof chatPlatformBan + | typeof chatPlayerCooldown + | typeof chatRoomMute + | typeof chatRoomBan; + +type RoomRef = Pick; + +// UNION ALL pairs columns by position, so every branch selects these keys in this order. +function restrictionFields( + type: ChatRoomRestrictionType, + source: ChatRoomRestrictionSource, + table: T, + columns: { + scope: Column | SQL; + roomId: Column; + reason: Column | SQL; + setById: Column; + cooldownSeconds?: Column; + }, +) { + return { + id: table.id, + type: new SQL.Aliased(sql`${type}::text`, 'type'), + source: new SQL.Aliased(sql`${source}::text`, 'source'), + userId: table.userId, + scope: new SQL.Aliased(sql`${columns.scope}::text`, 'scope'), + roomId: new SQL.Aliased(sql`${columns.roomId}`, 'room_id'), + reason: new SQL.Aliased(sql`${columns.reason}`, 'reason'), + setById: new SQL.Aliased(sql`${columns.setById}`, 'set_by_id'), + createdAt: table.createdAt, + expiresAt: table.expiresAt, + cooldownSeconds: new SQL.Aliased( + sql`${columns.cooldownSeconds ?? sql`null`}::integer`, + 'cooldown_seconds', + ), + }; +} + +const isActive = (table: RestrictionTable, now: Date) => + and(isNull(table.liftedAt), or(isNull(table.expiresAt), gt(table.expiresAt, now))); + +export class ChatRoomRestrictionService { + constructor( + private readonly drizzle: DrizzleService, + private readonly directory: AdminUserDirectory, + ) {} + + async listRoomRestrictions({ roomId, type, page, limit }: ListRoomRestrictionsInput) { + const [room] = await this.drizzle.db + .select({ id: chatRoom.id, slug: chatRoom.slug, isPublic: chatRoom.isPublic }) + .from(chatRoom) + .where( + and( + roomId === GLOBAL_CHAT_ROOM_ID + ? eq(chatRoom.slug, GLOBAL_CHAT_ROOM_ID) + : eq(chatRoom.id, roomId), + isNull(chatRoom.deletedAt), + ), + ) + .limit(1); + if (!room) { + throw new ChatRoomNotFoundError(roomId); + } + const restrictions = this.restrictionsReaching(room, type, new Date()).as('restrictions'); + const [rows, [{ total }]] = await Promise.all([ + this.drizzle.db + .select() + .from(restrictions) + .orderBy(desc(restrictions.createdAt), desc(restrictions.id)) + .limit(limit) + .offset(pageToOffset(page, limit)), + this.drizzle.db.select({ total: count() }).from(restrictions), + ]); + return { items: await this.withNames(rows), total, page, limit }; + } + + private restrictionsReaching( + room: RoomRef, + type: ChatRoomRestrictionType | undefined, + now: Date, + ) { + const db = this.drizzle.db; + const platformScopes = platformScopesFor(roomReach(room)); + const reachesRoom = ( + table: typeof chatMute | typeof chatPlatformBan | typeof chatPlayerCooldown, + ) => + or( + inArray(table.scope, platformScopes), + and(eq(table.scope, 'room'), eq(table.roomId, room.id)), + ); + const ofType = (branchType: ChatRoomRestrictionType) => + type === undefined || type === branchType ? undefined : sql`false`; + const roomScope = sql`'room'`; + return unionAll( + db + .select( + restrictionFields('mute', 'admin', chatMute, { + scope: chatMute.scope, + roomId: chatMute.roomId, + reason: chatMute.reason, + setById: chatMute.mutedBy, + }), + ) + .from(chatMute) + .where(and(ofType('mute'), isActive(chatMute, now), reachesRoom(chatMute))), + db + .select( + restrictionFields('mute', 'room', chatRoomMute, { + scope: roomScope, + roomId: chatRoomMute.roomId, + reason: chatRoomMute.reason, + setById: chatRoomMute.mutedBy, + }), + ) + .from(chatRoomMute) + .where(and(ofType('mute'), isActive(chatRoomMute, now), eq(chatRoomMute.roomId, room.id))), + db + .select( + restrictionFields('ban', 'admin', chatPlatformBan, { + scope: chatPlatformBan.scope, + roomId: chatPlatformBan.roomId, + reason: chatPlatformBan.reason, + setById: chatPlatformBan.bannedBy, + }), + ) + .from(chatPlatformBan) + .where(and(ofType('ban'), isActive(chatPlatformBan, now), reachesRoom(chatPlatformBan))), + db + .select( + restrictionFields('ban', 'room', chatRoomBan, { + scope: roomScope, + roomId: chatRoomBan.roomId, + reason: sql`null::text`, + setById: chatRoomBan.bannedBy, + }), + ) + .from(chatRoomBan) + .where(and(ofType('ban'), isActive(chatRoomBan, now), eq(chatRoomBan.roomId, room.id))), + db + .select( + restrictionFields('cooldown', 'admin', chatPlayerCooldown, { + scope: chatPlayerCooldown.scope, + roomId: chatPlayerCooldown.roomId, + reason: chatPlayerCooldown.reason, + setById: chatPlayerCooldown.createdBy, + cooldownSeconds: chatPlayerCooldown.cooldownSeconds, + }), + ) + .from(chatPlayerCooldown) + .where( + and( + ofType('cooldown'), + isActive(chatPlayerCooldown, now), + reachesRoom(chatPlayerCooldown), + ), + ), + ); + } + + // A room moderator is a player and shows by username; staff show by account name. + private async withNames( + rows: (Omit & { + setById: Uuid; + createdAt: Date; + expiresAt: Date | null; + })[], + ): Promise { + const players = await this.directory.lookupPlayers([ + ...new Set(rows.flatMap((row) => [row.userId, row.setById])), + ]); + const usernames = new Map(players.map((summary) => [summary.userId, summary.username])); + const staffIds = [...new Set(rows.map((row) => row.setById))].filter( + (id) => !usernames.has(id), + ); + const staff = staffIds.length > 0 ? await this.directory.lookupUsers(staffIds) : []; + const staffNames = new Map(staff.map((account) => [account.id, account.name])); + return rows.map(({ setById, createdAt, expiresAt, ...row }) => ({ + ...row, + username: usernames.get(row.userId) ?? null, + setBy: { id: setById, name: usernames.get(setById) ?? staffNames.get(setById) ?? null }, + createdAt: createdAt.toISOString(), + expiresAt: expiresAt?.toISOString() ?? null, + })); + } +} diff --git a/packages/testing/src/__tests__/chat-room-restrictions.e2e.test.ts b/packages/testing/src/__tests__/chat-room-restrictions.e2e.test.ts new file mode 100644 index 00000000..8db1d438 --- /dev/null +++ b/packages/testing/src/__tests__/chat-room-restrictions.e2e.test.ts @@ -0,0 +1,381 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { eq } from 'drizzle-orm'; +import { DRIZZLE, loadExtensions } from '@openora/core/server'; +import { adminRole, adminRolePermission, adminRoleAssignment } from '@openora/core/iam/schema'; +import { user } from '@openora/core/pam/schema/identity'; +import { GLOBAL_CHAT_ROOM_ID } from '@openora/core/contracts'; +import { paginated } from '@openora/core/contracts/kit'; +import { + ChatRoomRestrictionSchema, + ChatRoomSchema, + type ChatRoomRestriction, +} from '@openora/core/engagement/contracts/chat'; +import { + chatMute, + chatPlatformBan, + chatRoomBan, + chatRoomMute, +} from '@openora/core/engagement/schema/chat'; +import { + setupTestDb, + bootTestApp, + asAdmin, + registerAndMaterializePlayer, + seedMinimal, + type TestDb, + type TestApp, + type TestClient, +} from '../index.js'; + +const RestrictionPageSchema = paginated(ChatRoomRestrictionSchema); +const HOUR_MS = 3_600_000; + +let db: TestDb; +let app: TestApp; +let admin: TestClient; +let adminAccount: { id: string; name: string }; +let globalRoomId: string; + +async function registerChatter(prefix: string) { + const username = `${prefix.slice(0, 7)}_${randomUUID().replaceAll('-', '').slice(0, 10)}`; + const registered = await registerAndMaterializePlayer(app, { + email: `${username}@e2e.test`, + username, + }); + return { ...registered, username }; +} + +async function registerBackofficeViewer(resource: 'chat-room' | 'chat-moderation') { + const viewer = await registerChatter('viewer'); + const drizzle = app.container.get(DRIZZLE).db; + await drizzle.update(user).set({ role: 'admin' }).where(eq(user.id, viewer.userId)); + const [role] = await drizzle + .insert(adminRole) + .values({ name: `${resource} viewer ${randomUUID()}` }) + .returning({ id: adminRole.id }); + await drizzle.insert(adminRolePermission).values({ roleId: role!.id, resource, level: 'read' }); + await drizzle.insert(adminRoleAssignment).values({ userId: viewer.userId, roleId: role!.id }); + return viewer; +} + +async function createPublicRoom() { + const suffix = randomUUID().slice(0, 8); + const created = await admin.post('/backoffice/chat/rooms', { + name: `Lobby ${suffix}`, + slug: `lobby-${suffix}`, + category: 'games-sports', + }); + expect(created.status).toBe(200); + return ChatRoomSchema.parse(await created.json()); +} + +async function createPrivateRoomWith(owner: TestClient, members: { client: TestClient }[]) { + const created = await owner.post('/chat/rooms/private', { name: `room-${randomUUID()}` }); + expect(created.status).toBe(200); + const room = ChatRoomSchema.parse(await created.json()); + for (const member of members) { + expect((await member.client.post('/chat/rooms/join', { joinCode: room.joinCode })).status).toBe( + 200, + ); + } + return room; +} + +async function adminRestrict( + kind: 'mutes' | 'bans' | 'cooldowns', + userId: string, + roomId: string, + durationSeconds: number | null = null, +) { + const response = await admin.post(`/backoffice/chat/${kind}`, { + userId, + roomId, + reason: `${kind} for ${userId}`, + durationSeconds, + ...(kind === 'cooldowns' ? { cooldownSeconds: 30 } : {}), + }); + expect(response.status).toBe(200); +} + +async function listRestrictions(roomId: string, query = '', client: TestClient = admin) { + const response = await client.get(`/backoffice/chat/rooms/${roomId}/restrictions${query}`); + expect(response.status).toBe(200); + return RestrictionPageSchema.parse(await response.json()); +} + +const forUser = (items: ChatRoomRestriction[], userId: string) => + items.filter((item) => item.userId === userId); + +beforeAll(async () => { + process.env['BETTER_AUTH_SECRET'] ??= 'e2e-test-better-auth-secret-please-change-000000'; + process.env['AUTH_SECRET'] ??= process.env['BETTER_AUTH_SECRET']; + process.env['WITHDRAWAL_PIN_HMAC_SECRET'] ??= 'e2e-test-withdrawal-pin-hmac-secret-000000'; + process.env['NODE_ENV'] ??= 'test'; + + db = await setupTestDb(); + app = await bootTestApp({ plugins: await loadExtensions(), databaseUrl: db.url }); + await seedMinimal(app.container, { playerCount: 0 }); + admin = await asAdmin(app.app); + const [account] = await app.container + .get(DRIZZLE) + .db.select({ id: user.id, name: user.name }) + .from(user) + .where(eq(user.email, 'admin@oss.dev')); + adminAccount = account!; + const rooms = ChatRoomSchema.array().parse(await (await app.app.request('/chat/rooms')).json()); + const globalRoom = rooms.find((room) => room.slug === GLOBAL_CHAT_ROOM_ID); + if (!globalRoom) { + throw new Error('global chat room is not seeded'); + } + globalRoomId = globalRoom.id; +}, 60_000); + +afterAll(async () => { + await app?.close(); + await db?.dispose(); +}); + +describe('chat admin: listing the restrictions in a room', () => { + it('lists room-moderator and staff restrictions with who set them', async () => { + const owner = await registerChatter('host'); + const flooder = await registerChatter('flooder'); + const troll = await registerChatter('troll'); + const room = await createPrivateRoomWith(owner.client, [flooder, troll]); + const everywhere = await registerChatter('every'); + const throttled = await registerChatter('slowed'); + expect( + ( + await owner.client.post(`/chat/rooms/${room.id}/mute`, { + userId: flooder.userId, + reason: 'flooding', + durationSeconds: 3600, + }) + ).status, + ).toBe(200); + expect( + (await owner.client.post(`/chat/rooms/${room.id}/ban`, { userId: troll.userId })).status, + ).toBe(200); + await adminRestrict('mutes', everywhere.userId, '__all'); + await adminRestrict('cooldowns', throttled.userId, '__all', 3600); + + const { items } = await listRestrictions(room.id); + + expect(forUser(items, flooder.userId)).toEqual([ + { + id: expect.any(String), + type: 'mute', + source: 'room', + userId: flooder.userId, + username: flooder.username, + scope: 'room', + roomId: room.id, + reason: 'flooding', + setBy: { id: owner.userId, name: owner.username }, + createdAt: expect.any(String), + expiresAt: expect.any(String), + cooldownSeconds: null, + }, + ]); + expect(forUser(items, troll.userId)).toEqual([ + expect.objectContaining({ + type: 'ban', + source: 'room', + scope: 'room', + roomId: room.id, + reason: null, + setBy: { id: owner.userId, name: owner.username }, + expiresAt: null, + }), + ]); + expect(forUser(items, everywhere.userId)).toEqual([ + expect.objectContaining({ + type: 'mute', + source: 'admin', + username: everywhere.username, + scope: '__all', + roomId: null, + reason: `mutes for ${everywhere.userId}`, + setBy: { id: adminAccount.id, name: adminAccount.name }, + }), + ]); + expect(forUser(items, throttled.userId)).toEqual([ + expect.objectContaining({ + type: 'cooldown', + source: 'admin', + username: throttled.username, + scope: '__all', + roomId: null, + reason: `cooldowns for ${throttled.userId}`, + setBy: { id: adminAccount.id, name: adminAccount.name }, + expiresAt: expect.any(String), + cooldownSeconds: 30, + }), + ]); + }); + + it('reaches each room kind only through the platform scopes that cover it', async () => { + const room = await createPublicRoom(); + const otherRoom = await createPublicRoom(); + const owner = await registerChatter('host'); + const roomMuted = await registerChatter('inroom'); + const publicBanned = await registerChatter('public'); + const allMuted = await registerChatter('all'); + const globalMuted = await registerChatter('global'); + const elsewhere = await registerChatter('else'); + const publicSlowed = await registerChatter('slowed'); + const privateRoom = await createPrivateRoomWith(owner.client, []); + await adminRestrict('mutes', roomMuted.userId, room.id); + await adminRestrict('bans', publicBanned.userId, '__all_public'); + await adminRestrict('mutes', allMuted.userId, '__all'); + await adminRestrict('mutes', globalMuted.userId, GLOBAL_CHAT_ROOM_ID); + await adminRestrict('bans', elsewhere.userId, otherRoom.id); + await adminRestrict('cooldowns', publicSlowed.userId, '__all_public'); + const seeded = [roomMuted, publicBanned, allMuted, globalMuted, elsewhere, publicSlowed].map( + ({ userId }) => userId, + ); + const reached = async (roomId: string) => + (await listRestrictions(roomId)).items + .filter((item) => seeded.includes(item.userId)) + .map((item) => `${item.type}:${item.scope}:${item.userId}`) + .sort(); + + expect(await reached(room.id)).toEqual( + [ + `mute:room:${roomMuted.userId}`, + `ban:__all_public:${publicBanned.userId}`, + `mute:__all:${allMuted.userId}`, + `cooldown:__all_public:${publicSlowed.userId}`, + ].sort(), + ); + expect(await reached(GLOBAL_CHAT_ROOM_ID)).toEqual( + [ + `ban:__all_public:${publicBanned.userId}`, + `mute:__all:${allMuted.userId}`, + `mute:__global:${globalMuted.userId}`, + `cooldown:__all_public:${publicSlowed.userId}`, + ].sort(), + ); + expect(await reached(globalRoomId)).toEqual(await reached(GLOBAL_CHAT_ROOM_ID)); + expect(await reached(privateRoom.id)).toEqual([`mute:__all:${allMuted.userId}`]); + }); + + it('leaves out lifted and expired restrictions', async () => { + const room = await createPublicRoom(); + const [active, lifted, expired, expiredInRoom, liftedInRoom] = await Promise.all( + ['active', 'lifted', 'expired', 'expired', 'lifted'].map((prefix) => registerChatter(prefix)), + ); + await adminRestrict('mutes', active!.userId, room.id); + await adminRestrict('bans', lifted!.userId, room.id); + await adminRestrict('cooldowns', lifted!.userId, room.id); + expect( + ( + await admin.post('/backoffice/chat/cooldowns/lift', { + userId: lifted!.userId, + roomId: room.id, + }) + ).status, + ).toBe(200); + expect( + (await admin.post('/backoffice/chat/bans/lift', { userId: lifted!.userId, roomId: room.id })) + .status, + ).toBe(200); + const drizzle = app.container.get(DRIZZLE).db; + const past = new Date(Date.now() - HOUR_MS); + await drizzle.insert(chatMute).values({ + userId: expired!.userId, + roomId: room.id, + scope: 'room', + mutedBy: adminAccount.id, + reason: 'lapsed', + createdAt: new Date(Date.now() - 2 * HOUR_MS), + expiresAt: past, + }); + await drizzle.insert(chatPlatformBan).values({ + userId: expired!.userId, + roomId: null, + scope: '__all_public', + bannedBy: adminAccount.id, + reason: 'lapsed', + createdAt: new Date(Date.now() - 2 * HOUR_MS), + expiresAt: past, + }); + await drizzle.insert(chatRoomMute).values({ + roomId: room.id, + userId: expiredInRoom!.userId, + mutedBy: adminAccount.id, + reason: 'lapsed', + expiresAt: past, + }); + await drizzle.insert(chatRoomBan).values({ + roomId: room.id, + userId: liftedInRoom!.userId, + bannedBy: adminAccount.id, + liftedAt: past, + liftedBy: adminAccount.id, + }); + + const { items } = await listRestrictions(room.id); + + expect(forUser(items, active!.userId)).toHaveLength(1); + for (const gone of [lifted!, expired!, expiredInRoom!, liftedInRoom!]) { + expect(forUser(items, gone.userId)).toEqual([]); + } + }); + + it('filters by type and pages newest first', async () => { + const owner = await registerChatter('host'); + const members = await Promise.all( + ['m1', 'm2', 'm3', 'm4'].map((prefix) => registerChatter(prefix)), + ); + const room = await createPrivateRoomWith(owner.client, members); + for (const member of members.slice(0, 3)) { + expect( + (await owner.client.post(`/chat/rooms/${room.id}/mute`, { userId: member.userId })).status, + ).toBe(200); + } + expect( + (await owner.client.post(`/chat/rooms/${room.id}/ban`, { userId: members[3]!.userId })) + .status, + ).toBe(200); + + const mutes = await listRestrictions(room.id, '?type=mute'); + const bans = await listRestrictions(room.id, '?type=ban'); + const cooldowns = await listRestrictions(room.id, '?type=cooldown'); + const everything = await listRestrictions(room.id); + const firstPage = await listRestrictions(room.id, '?type=mute&page=1&limit=2'); + const secondPage = await listRestrictions(room.id, '?type=mute&page=2&limit=2'); + + expect(mutes.items.every((item) => item.type === 'mute')).toBe(true); + expect(bans.items.every((item) => item.type === 'ban')).toBe(true); + expect(cooldowns.items.every((item) => item.type === 'cooldown')).toBe(true); + expect(everything.total).toBe(mutes.total + bans.total + cooldowns.total); + expect(mutes.items.map((item) => item.userId)).toEqual( + expect.arrayContaining(members.slice(0, 3).map((member) => member.userId)), + ); + expect(forUser(bans.items, members[3]!.userId)).toHaveLength(1); + const createdAt = everything.items.map((item) => Date.parse(item.createdAt)); + expect(createdAt).toEqual([...createdAt].sort((a, b) => b - a)); + expect(firstPage).toMatchObject({ total: mutes.total, page: 1, limit: 2 }); + expect(firstPage.items).toHaveLength(2); + expect([...firstPage.items, ...secondPage.items]).toEqual(mutes.items.slice(0, 4)); + }); + + it('refuses a caller without the chat-moderation permission and an unknown or deleted room', async () => { + const room = await createPublicRoom(); + const player = await registerChatter('player'); + const roomViewer = await registerBackofficeViewer('chat-room'); + const moderationViewer = await registerBackofficeViewer('chat-moderation'); + const path = `/backoffice/chat/rooms/${room.id}/restrictions`; + + expect((await app.app.request(path)).status).toBe(401); + expect((await player.client.get(path)).status).toBe(403); + expect((await roomViewer.client.get(path)).status).toBe(403); + expect((await moderationViewer.client.get(path)).status).toBe(200); + expect((await admin.get(`/backoffice/chat/rooms/${randomUUID()}/restrictions`)).status).toBe( + 404, + ); + expect((await admin.del(`/backoffice/chat/rooms/${room.id}`)).status).toBe(200); + expect((await admin.get(path)).status).toBe(404); + }); +}); From c40db021aabe95ff73259061a6fd16dde2dda00f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oleksandr=20Kapitu=C5=82a?= Date: Mon, 5 Oct 2026 12:53:50 +0200 Subject: [PATCH 2/4] test(chat): seed the room restrictions cooldown filter case in its own test --- .../chat-room-restrictions.e2e.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/testing/src/__tests__/chat-room-restrictions.e2e.test.ts b/packages/testing/src/__tests__/chat-room-restrictions.e2e.test.ts index 8db1d438..46d8cf22 100644 --- a/packages/testing/src/__tests__/chat-room-restrictions.e2e.test.ts +++ b/packages/testing/src/__tests__/chat-room-restrictions.e2e.test.ts @@ -338,6 +338,10 @@ describe('chat admin: listing the restrictions in a room', () => { (await owner.client.post(`/chat/rooms/${room.id}/ban`, { userId: members[3]!.userId })) .status, ).toBe(200); + const publicRoom = await createPublicRoom(); + const otherRoom = await createPublicRoom(); + const slowed = await registerChatter('slowed'); + await adminRestrict('cooldowns', slowed.userId, publicRoom.id); const mutes = await listRestrictions(room.id, '?type=mute'); const bans = await listRestrictions(room.id, '?type=ban'); @@ -345,7 +349,22 @@ describe('chat admin: listing the restrictions in a room', () => { const everything = await listRestrictions(room.id); const firstPage = await listRestrictions(room.id, '?type=mute&page=1&limit=2'); const secondPage = await listRestrictions(room.id, '?type=mute&page=2&limit=2'); + const publicCooldowns = await listRestrictions(publicRoom.id, '?type=cooldown'); + expect(forUser(publicCooldowns.items, slowed.userId)).toEqual([ + expect.objectContaining({ + type: 'cooldown', + source: 'admin', + scope: 'room', + roomId: publicRoom.id, + cooldownSeconds: 30, + }), + ]); + expect(publicCooldowns.items.every((item) => item.type === 'cooldown')).toBe(true); + expect( + forUser((await listRestrictions(publicRoom.id, '?type=mute')).items, slowed.userId), + ).toEqual([]); + expect(forUser((await listRestrictions(otherRoom.id)).items, slowed.userId)).toEqual([]); expect(mutes.items.every((item) => item.type === 'mute')).toBe(true); expect(bans.items.every((item) => item.type === 'ban')).toBe(true); expect(cooldowns.items.every((item) => item.type === 'cooldown')).toBe(true); From 13163299ad4c5bad48d9173a0185f93716fe8a81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oleksandr=20Kapitu=C5=82a?= Date: Mon, 5 Oct 2026 14:23:08 +0200 Subject: [PATCH 3/4] refactor(chat): rename the room reference input to RoomRefInput --- packages/core/src/engagement/chat/contract/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/engagement/chat/contract/index.ts b/packages/core/src/engagement/chat/contract/index.ts index 0b723f1b..d7fd5fa3 100644 --- a/packages/core/src/engagement/chat/contract/index.ts +++ b/packages/core/src/engagement/chat/contract/index.ts @@ -335,8 +335,8 @@ export const AdminLiftChatCooldownInputSchema = z.object({ }); const RoomIdInput = z.object({ roomId: UuidSchema }); -const RoomRulesInput = z.object({ roomId: UuidSchema.or(z.literal(GLOBAL_CHAT_ROOM_ID)) }); -const ListRoomRestrictionsInputSchema = RoomRulesInput.extend({ +const RoomRefInput = z.object({ roomId: UuidSchema.or(z.literal(GLOBAL_CHAT_ROOM_ID)) }); +const ListRoomRestrictionsInputSchema = RoomRefInput.extend({ type: ChatRoomRestrictionTypeSchema.optional(), ...PageQuerySchema.shape, }); @@ -558,7 +558,7 @@ export const chatContract = { getRoomRules: oc .route({ method: 'GET', path: '/chat/rooms/{roomId}/rules' }) - .input(RoomRulesInput) + .input(RoomRefInput) .output(z.array(ChatRoomRuleSchema)), createRoomRule: oc From 7d8f674bcb4382dafd0d0050d4184990ffe6dd88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oleksandr=20Kapitu=C5=82a?= Date: Mon, 5 Oct 2026 14:23:15 +0200 Subject: [PATCH 4/4] fix(chat): name a restriction's setter from the table its source points at Room-moderator rows resolve the setter's player username and staff rows the account name, so an id that also matches the other table no longer picks the wrong one. The row type is inferred from the union query, and setBy { id, name } becomes setByName so staff ids are not exposed. --- .../src/engagement/chat/contract/index.ts | 2 +- .../service/chat-room-restriction.service.ts | 191 +++++++++--------- .../chat-room-restrictions.e2e.test.ts | 41 +++- 3 files changed, 129 insertions(+), 105 deletions(-) diff --git a/packages/core/src/engagement/chat/contract/index.ts b/packages/core/src/engagement/chat/contract/index.ts index d7fd5fa3..34b4e474 100644 --- a/packages/core/src/engagement/chat/contract/index.ts +++ b/packages/core/src/engagement/chat/contract/index.ts @@ -307,7 +307,7 @@ export const ChatRoomRestrictionSchema = ChatModerationEntrySchema.extend({ username: z.string().nullable(), // Null for a room-moderator ban, which records no reason. reason: z.string().nullable(), - setBy: z.object({ id: UuidSchema, name: z.string().nullable() }), + setByName: z.string().nullable(), cooldownSeconds: z.number().int().positive().nullable(), }); export type ChatRoomRestriction = z.infer; diff --git a/packages/core/src/engagement/chat/service/chat-room-restriction.service.ts b/packages/core/src/engagement/chat/service/chat-room-restriction.service.ts index 18a15d20..dc998536 100644 --- a/packages/core/src/engagement/chat/service/chat-room-restriction.service.ts +++ b/packages/core/src/engagement/chat/service/chat-room-restriction.service.ts @@ -67,6 +67,87 @@ function restrictionFields( const isActive = (table: RestrictionTable, now: Date) => and(isNull(table.liftedAt), or(isNull(table.expiresAt), gt(table.expiresAt, now))); +function restrictionsReaching( + db: DrizzleService['db'], + room: RoomRef, + type: ChatRoomRestrictionType | undefined, + now: Date, +) { + const platformScopes = platformScopesFor(roomReach(room)); + const reachesRoom = ( + table: typeof chatMute | typeof chatPlatformBan | typeof chatPlayerCooldown, + ) => + or( + inArray(table.scope, platformScopes), + and(eq(table.scope, 'room'), eq(table.roomId, room.id)), + ); + const ofType = (branchType: ChatRoomRestrictionType) => + type === undefined || type === branchType ? undefined : sql`false`; + const roomScope = sql`'room'`; + return unionAll( + db + .select( + restrictionFields('mute', 'admin', chatMute, { + scope: chatMute.scope, + roomId: chatMute.roomId, + reason: chatMute.reason, + setById: chatMute.mutedBy, + }), + ) + .from(chatMute) + .where(and(ofType('mute'), isActive(chatMute, now), reachesRoom(chatMute))), + db + .select( + restrictionFields('mute', 'room', chatRoomMute, { + scope: roomScope, + roomId: chatRoomMute.roomId, + reason: chatRoomMute.reason, + setById: chatRoomMute.mutedBy, + }), + ) + .from(chatRoomMute) + .where(and(ofType('mute'), isActive(chatRoomMute, now), eq(chatRoomMute.roomId, room.id))), + db + .select( + restrictionFields('ban', 'admin', chatPlatformBan, { + scope: chatPlatformBan.scope, + roomId: chatPlatformBan.roomId, + reason: chatPlatformBan.reason, + setById: chatPlatformBan.bannedBy, + }), + ) + .from(chatPlatformBan) + .where(and(ofType('ban'), isActive(chatPlatformBan, now), reachesRoom(chatPlatformBan))), + db + .select( + restrictionFields('ban', 'room', chatRoomBan, { + scope: roomScope, + roomId: chatRoomBan.roomId, + reason: sql`null::text`, + setById: chatRoomBan.bannedBy, + }), + ) + .from(chatRoomBan) + .where(and(ofType('ban'), isActive(chatRoomBan, now), eq(chatRoomBan.roomId, room.id))), + db + .select( + restrictionFields('cooldown', 'admin', chatPlayerCooldown, { + scope: chatPlayerCooldown.scope, + roomId: chatPlayerCooldown.roomId, + reason: chatPlayerCooldown.reason, + setById: chatPlayerCooldown.createdBy, + cooldownSeconds: chatPlayerCooldown.cooldownSeconds, + }), + ) + .from(chatPlayerCooldown) + .where( + and(ofType('cooldown'), isActive(chatPlayerCooldown, now), reachesRoom(chatPlayerCooldown)), + ), + ); +} + +type RestrictionRow = Awaited>[number]; + export class ChatRoomRestrictionService { constructor( private readonly drizzle: DrizzleService, @@ -89,7 +170,9 @@ export class ChatRoomRestrictionService { if (!room) { throw new ChatRoomNotFoundError(roomId); } - const restrictions = this.restrictionsReaching(room, type, new Date()).as('restrictions'); + const restrictions = restrictionsReaching(this.drizzle.db, room, type, new Date()).as( + 'restrictions', + ); const [rows, [{ total }]] = await Promise.all([ this.drizzle.db .select() @@ -102,110 +185,22 @@ export class ChatRoomRestrictionService { return { items: await this.withNames(rows), total, page, limit }; } - private restrictionsReaching( - room: RoomRef, - type: ChatRoomRestrictionType | undefined, - now: Date, - ) { - const db = this.drizzle.db; - const platformScopes = platformScopesFor(roomReach(room)); - const reachesRoom = ( - table: typeof chatMute | typeof chatPlatformBan | typeof chatPlayerCooldown, - ) => - or( - inArray(table.scope, platformScopes), - and(eq(table.scope, 'room'), eq(table.roomId, room.id)), - ); - const ofType = (branchType: ChatRoomRestrictionType) => - type === undefined || type === branchType ? undefined : sql`false`; - const roomScope = sql`'room'`; - return unionAll( - db - .select( - restrictionFields('mute', 'admin', chatMute, { - scope: chatMute.scope, - roomId: chatMute.roomId, - reason: chatMute.reason, - setById: chatMute.mutedBy, - }), - ) - .from(chatMute) - .where(and(ofType('mute'), isActive(chatMute, now), reachesRoom(chatMute))), - db - .select( - restrictionFields('mute', 'room', chatRoomMute, { - scope: roomScope, - roomId: chatRoomMute.roomId, - reason: chatRoomMute.reason, - setById: chatRoomMute.mutedBy, - }), - ) - .from(chatRoomMute) - .where(and(ofType('mute'), isActive(chatRoomMute, now), eq(chatRoomMute.roomId, room.id))), - db - .select( - restrictionFields('ban', 'admin', chatPlatformBan, { - scope: chatPlatformBan.scope, - roomId: chatPlatformBan.roomId, - reason: chatPlatformBan.reason, - setById: chatPlatformBan.bannedBy, - }), - ) - .from(chatPlatformBan) - .where(and(ofType('ban'), isActive(chatPlatformBan, now), reachesRoom(chatPlatformBan))), - db - .select( - restrictionFields('ban', 'room', chatRoomBan, { - scope: roomScope, - roomId: chatRoomBan.roomId, - reason: sql`null::text`, - setById: chatRoomBan.bannedBy, - }), - ) - .from(chatRoomBan) - .where(and(ofType('ban'), isActive(chatRoomBan, now), eq(chatRoomBan.roomId, room.id))), - db - .select( - restrictionFields('cooldown', 'admin', chatPlayerCooldown, { - scope: chatPlayerCooldown.scope, - roomId: chatPlayerCooldown.roomId, - reason: chatPlayerCooldown.reason, - setById: chatPlayerCooldown.createdBy, - cooldownSeconds: chatPlayerCooldown.cooldownSeconds, - }), - ) - .from(chatPlayerCooldown) - .where( - and( - ofType('cooldown'), - isActive(chatPlayerCooldown, now), - reachesRoom(chatPlayerCooldown), - ), - ), - ); - } - // A room moderator is a player and shows by username; staff show by account name. - private async withNames( - rows: (Omit & { - setById: Uuid; - createdAt: Date; - expiresAt: Date | null; - })[], - ): Promise { - const players = await this.directory.lookupPlayers([ - ...new Set(rows.flatMap((row) => [row.userId, row.setById])), + private async withNames(rows: RestrictionRow[]): Promise { + const playerIds = rows.flatMap((row) => + row.source === 'room' ? [row.userId, row.setById] : [row.userId], + ); + const staffIds = rows.filter((row) => row.source === 'admin').map((row) => row.setById); + const [players, staff] = await Promise.all([ + this.directory.lookupPlayers([...new Set(playerIds)]), + this.directory.lookupUsers([...new Set(staffIds)]), ]); const usernames = new Map(players.map((summary) => [summary.userId, summary.username])); - const staffIds = [...new Set(rows.map((row) => row.setById))].filter( - (id) => !usernames.has(id), - ); - const staff = staffIds.length > 0 ? await this.directory.lookupUsers(staffIds) : []; const staffNames = new Map(staff.map((account) => [account.id, account.name])); return rows.map(({ setById, createdAt, expiresAt, ...row }) => ({ ...row, username: usernames.get(row.userId) ?? null, - setBy: { id: setById, name: usernames.get(setById) ?? staffNames.get(setById) ?? null }, + setByName: (row.source === 'room' ? usernames.get(setById) : staffNames.get(setById)) ?? null, createdAt: createdAt.toISOString(), expiresAt: expiresAt?.toISOString() ?? null, })); diff --git a/packages/testing/src/__tests__/chat-room-restrictions.e2e.test.ts b/packages/testing/src/__tests__/chat-room-restrictions.e2e.test.ts index 46d8cf22..08b60f16 100644 --- a/packages/testing/src/__tests__/chat-room-restrictions.e2e.test.ts +++ b/packages/testing/src/__tests__/chat-room-restrictions.e2e.test.ts @@ -46,7 +46,10 @@ async function registerChatter(prefix: string) { return { ...registered, username }; } -async function registerBackofficeViewer(resource: 'chat-room' | 'chat-moderation') { +async function registerBackofficeViewer( + resource: 'chat-room' | 'chat-moderation', + level: 'read' | 'read_write' = 'read', +) { const viewer = await registerChatter('viewer'); const drizzle = app.container.get(DRIZZLE).db; await drizzle.update(user).set({ role: 'admin' }).where(eq(user.id, viewer.userId)); @@ -54,7 +57,7 @@ async function registerBackofficeViewer(resource: 'chat-room' | 'chat-moderation .insert(adminRole) .values({ name: `${resource} viewer ${randomUUID()}` }) .returning({ id: adminRole.id }); - await drizzle.insert(adminRolePermission).values({ roleId: role!.id, resource, level: 'read' }); + await drizzle.insert(adminRolePermission).values({ roleId: role!.id, resource, level }); await drizzle.insert(adminRoleAssignment).values({ userId: viewer.userId, roleId: role!.id }); return viewer; } @@ -171,7 +174,7 @@ describe('chat admin: listing the restrictions in a room', () => { scope: 'room', roomId: room.id, reason: 'flooding', - setBy: { id: owner.userId, name: owner.username }, + setByName: owner.username, createdAt: expect.any(String), expiresAt: expect.any(String), cooldownSeconds: null, @@ -184,7 +187,7 @@ describe('chat admin: listing the restrictions in a room', () => { scope: 'room', roomId: room.id, reason: null, - setBy: { id: owner.userId, name: owner.username }, + setByName: owner.username, expiresAt: null, }), ]); @@ -196,7 +199,7 @@ describe('chat admin: listing the restrictions in a room', () => { scope: '__all', roomId: null, reason: `mutes for ${everywhere.userId}`, - setBy: { id: adminAccount.id, name: adminAccount.name }, + setByName: adminAccount.name, }), ]); expect(forUser(items, throttled.userId)).toEqual([ @@ -207,7 +210,7 @@ describe('chat admin: listing the restrictions in a room', () => { scope: '__all', roomId: null, reason: `cooldowns for ${throttled.userId}`, - setBy: { id: adminAccount.id, name: adminAccount.name }, + setByName: adminAccount.name, expiresAt: expect.any(String), cooldownSeconds: 30, }), @@ -380,6 +383,32 @@ describe('chat admin: listing the restrictions in a room', () => { expect([...firstPage.items, ...secondPage.items]).toEqual(mutes.items.slice(0, 4)); }); + it('names a staff restriction by account name after its setter is back in the player role', async () => { + const room = await createPublicRoom(); + const moderator = await registerBackofficeViewer('chat-moderation', 'read_write'); + const muted = await registerChatter('muted'); + const staffName = `Moderator ${randomUUID().slice(0, 8)}`; + const drizzle = app.container.get(DRIZZLE).db; + await drizzle.update(user).set({ name: staffName }).where(eq(user.id, moderator.userId)); + expect( + ( + await moderator.client.post('/backoffice/chat/mutes', { + userId: muted.userId, + roomId: room.id, + reason: 'spam', + durationSeconds: null, + }) + ).status, + ).toBe(200); + await drizzle.update(user).set({ role: 'player' }).where(eq(user.id, moderator.userId)); + + const { items } = await listRestrictions(room.id); + + expect(forUser(items, muted.userId)).toEqual([ + expect.objectContaining({ source: 'admin', setByName: staffName }), + ]); + }); + it('refuses a caller without the chat-moderation permission and an unknown or deleted room', async () => { const room = await createPublicRoom(); const player = await registerChatter('player');