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..34b4e474 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(), + setByName: z.string().nullable(), + cooldownSeconds: z.number().int().positive().nullable(), +}); +export type ChatRoomRestriction = z.infer; + const AdminModerationInput = z.object({ userId: UuidSchema, reason: ModerationReasonSchema, @@ -317,7 +335,12 @@ 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 RoomRefInput = z.object({ roomId: UuidSchema.or(z.literal(GLOBAL_CHAT_ROOM_ID)) }); +const ListRoomRestrictionsInputSchema = RoomRefInput.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(''), @@ -535,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 @@ -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..dc998536 --- /dev/null +++ b/packages/core/src/engagement/chat/service/chat-room-restriction.service.ts @@ -0,0 +1,208 @@ +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))); + +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, + 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 = restrictionsReaching(this.drizzle.db, 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 }; + } + + // A room moderator is a player and shows by username; staff show by account name. + 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 staffNames = new Map(staff.map((account) => [account.id, account.name])); + return rows.map(({ setById, createdAt, expiresAt, ...row }) => ({ + ...row, + username: usernames.get(row.userId) ?? 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 new file mode 100644 index 00000000..08b60f16 --- /dev/null +++ b/packages/testing/src/__tests__/chat-room-restrictions.e2e.test.ts @@ -0,0 +1,429 @@ +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', + 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)); + 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 }); + 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', + setByName: 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, + setByName: 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}`, + setByName: 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}`, + setByName: 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 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'); + 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'); + 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); + 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('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'); + 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); + }); +});