diff --git a/apps/api/migrations/20261011193321_add_moderation_reports/migration.sql b/apps/api/migrations/20261011193321_add_moderation_reports/migration.sql new file mode 100644 index 0000000000..25f5e9d796 --- /dev/null +++ b/apps/api/migrations/20261011193321_add_moderation_reports/migration.sql @@ -0,0 +1,46 @@ +CREATE TABLE "core_moderation_report_events" ( + "id" serial PRIMARY KEY, + "reportId" integer NOT NULL, + "type" varchar(32) NOT NULL, + "actorId" integer, + "data" jsonb DEFAULT '{}' NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "core_moderation_report_events" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "core_moderation_reports" ( + "id" serial PRIMARY KEY, + "pluginId" varchar(100) NOT NULL, + "targetType" varchar(100) NOT NULL, + "targetId" varchar(64) NOT NULL, + "reporterId" integer, + "reason" varchar(32) NOT NULL, + "description" text, + "snapshot" jsonb NOT NULL, + "targetTitle" varchar(500), + "status" varchar(20) DEFAULT 'new' NOT NULL, + "assigneeId" integer, + "resolution" varchar(32), + "resolutionNote" text, + "resolvedAt" timestamp, + "resolvedById" integer, + "scopeFamily" varchar(100), + "scopeId" varchar(64), + "openKey" varchar(255) UNIQUE, + "version" integer DEFAULT 1 NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "core_moderation_reports" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE INDEX "core_moderation_report_events_report_idx" ON "core_moderation_report_events" ("reportId","id");--> statement-breakpoint +CREATE INDEX "core_moderation_reports_target_idx" ON "core_moderation_reports" ("pluginId","targetType","targetId");--> statement-breakpoint +CREATE INDEX "core_moderation_reports_status_idx" ON "core_moderation_reports" ("status","createdAt");--> statement-breakpoint +CREATE INDEX "core_moderation_reports_assignee_idx" ON "core_moderation_reports" ("assigneeId");--> statement-breakpoint +CREATE INDEX "core_moderation_reports_reporter_idx" ON "core_moderation_reports" ("reporterId","createdAt");--> statement-breakpoint +CREATE INDEX "core_moderation_reports_scope_idx" ON "core_moderation_reports" ("scopeFamily","scopeId");--> statement-breakpoint +ALTER TABLE "core_moderation_report_events" ADD CONSTRAINT "core_moderation_report_events_4CqHOxfheaUm_fkey" FOREIGN KEY ("reportId") REFERENCES "core_moderation_reports"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "core_moderation_report_events" ADD CONSTRAINT "core_moderation_report_events_actorId_core_users_id_fkey" FOREIGN KEY ("actorId") REFERENCES "core_users"("id") ON DELETE SET NULL;--> statement-breakpoint +ALTER TABLE "core_moderation_reports" ADD CONSTRAINT "core_moderation_reports_reporterId_core_users_id_fkey" FOREIGN KEY ("reporterId") REFERENCES "core_users"("id") ON DELETE SET NULL;--> statement-breakpoint +ALTER TABLE "core_moderation_reports" ADD CONSTRAINT "core_moderation_reports_assigneeId_core_users_id_fkey" FOREIGN KEY ("assigneeId") REFERENCES "core_users"("id") ON DELETE SET NULL;--> statement-breakpoint +ALTER TABLE "core_moderation_reports" ADD CONSTRAINT "core_moderation_reports_resolvedById_core_users_id_fkey" FOREIGN KEY ("resolvedById") REFERENCES "core_users"("id") ON DELETE SET NULL; \ No newline at end of file diff --git a/apps/api/migrations/20261011193321_add_moderation_reports/snapshot.json b/apps/api/migrations/20261011193321_add_moderation_reports/snapshot.json new file mode 100644 index 0000000000..060b6fcab9 --- /dev/null +++ b/apps/api/migrations/20261011193321_add_moderation_reports/snapshot.json @@ -0,0 +1,17707 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "3ad3093b-71a8-4478-b2ce-1bbcaa1ff602", + "prevIds": [ + "27a31513-4f49-4cab-aa26-2538019a87d9" + ], + "ddl": [ + { + "isRlsEnabled": true, + "name": "core_admin_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_admin_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_action_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_budget_periods", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_calls", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_cost_adjustments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_reservations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_role_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_role_policies", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_runs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_translation_sources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_user_overrides", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_documents", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_drafts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_field_locks", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_file_refs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_revisions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_schedules", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_slug_history", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_cron", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_admin_dashboard", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files_alt", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files_alt_analysis", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files_alt_state", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_languages", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_languages_words", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_logs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderation_report_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderation_reports", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderators_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_navigation", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_deliveries", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_receipts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_user_state", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notifications", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_page_layouts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkey_challenges", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_queue", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_search_index", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_secrets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions_known_devices", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_confirm_emails", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_forgot_password", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_secondary_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_operations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_profile_sources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_author_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_category_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_faq", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_related_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_articles_gallery", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_gallery", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_related_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_pages", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_tags_related_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_tags_translations", + "entityType": "tables", + "schema": "public" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actionKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fallbackModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxInputCharacters", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxOutputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "timeoutMs", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxRetries", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxSteps", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dailyLimit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "instructions", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "unit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodStart", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodEnd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "limitAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "spentAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "unknownCount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "attempt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerRequestId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "errorCode", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheReadTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheWriteTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reasoningTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "images", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costReason", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pricingVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pricingSnapshot", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finishedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reconciledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "callId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "previousCostUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "previousSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "newCostUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "newSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deltaUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "budgetPeriodId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "amount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'active'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "settledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "permission", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "granted", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dailyLimit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unlimited", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actionKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorType", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resourceType", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resourceId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "errorCode", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "promptVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pointsConversionVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheReadTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheWriteTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reasoningTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "chargedUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "chargedPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "settlement", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "leaseExpiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accepted", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "acceptedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finishedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "settledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyBudgetUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "systemMonthlyBudgetUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "defaultMonthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "pointsConversionVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "userRequestsPerMinute", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2", + "generated": null, + "identity": null, + "name": "userConcurrency", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2", + "generated": null, + "identity": null, + "name": "systemConcurrency", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "365", + "generated": null, + "identity": null, + "name": "historyRetentionDays", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "altEnabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "altBatchSize", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "altScanCursor", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "altScanStartedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceLocale", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedById", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unlimited", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "blocked", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(35)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseVersion", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "values", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "baseVersion", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "acquiredAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "operation", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "snapshot", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "changedFields", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'system'", + "generated": null, + "identity": null, + "name": "actorType", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorUserId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "restoredFromRevisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scheduledFor", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "generation", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "createdBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "effectsError", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "retiredAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastRun", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "module", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nextRun", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "schedule", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "widgets", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "folder", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mimeType", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "size", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'automatic'", + "generated": null, + "identity": null, + "name": "altPolicy", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "text", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedById", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'UTC'", + "generated": null, + "identity": null, + "name": "timezone", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "time24", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginCode", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tableName", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "variable", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(45)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'GET'", + "generated": null, + "identity": null, + "name": "method", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'localhost'", + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "500", + "generated": null, + "identity": null, + "name": "statusCode", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reportId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetType", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reporterId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "snapshot", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetTitle", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'new'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "assigneeId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolution", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolutionNote", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolvedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolvedById", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeFamily", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "openKey", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parentId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'header'", + "generated": null, + "identity": null, + "name": "location", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "presetId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "href", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isOpenInNewTab", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mode", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodStart", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodEnd", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "itemCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerMessageId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "skipReason", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sentAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "schemaVersion", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "recipientIds", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowSelf", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "recipientCursor", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "deliveredCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "notificationId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seq", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailPending", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "emailDeliveryId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "unreadCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "revision", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "preferences", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "groupBucket", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "latestEventId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "eventCount", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "activitySeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "readSeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "readAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "archivedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastActivityAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "zones", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ceremony", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "challenge", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(1024)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credentialId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicKey", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "counter", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "transports", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceType", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "backedUp", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(36)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "aaguid", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastUsedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'default'", + "generated": null, + "identity": null, + "name": "queue", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "priority", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "3", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reservedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dedupeKey", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "root", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "guest", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prefix", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowUploadFiles", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "totalMaxStorage", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxStorageForSubmit", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadAvatar", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2048", + "generated": null, + "identity": null, + "name": "maxAvatarSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadCover", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5120", + "generated": null, + "identity": null, + "name": "maxCoverSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowEditPersonalInfo", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "authorIds", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "tsvector", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": { + "as": "setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"title\", '')), 'A') || setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"content\", '')), 'B')", + "type": "stored" + }, + "identity": null, + "name": "search_vector", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isPublic", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isHidden", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "imageId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "indexedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nameCode", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "firstName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "phone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "headline", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "showRealName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "password", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "newsletter", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(6)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarColor", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailVerified", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "birthday", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "timeZone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'en'", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerEmail", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerUsername", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syncOnSignIn", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSourceUrl", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSha256", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarFileId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "intent", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "fields", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preview", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImage", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "friendlyUrl", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "excerpt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImageAlt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "syndicationIndexable", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syndicationNoIndex", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "syndicationPriority", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "question", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "answer", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoTitle", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoDescription", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "excerpt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "views", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "noIndex", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "animation", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": { + "type": "byDefault", + "name": "example_events_id_seq", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": 1, + "cycle": false + }, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "primaryTag", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_events_gallery" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_gallery" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_related_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_related_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_events_related_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_related_events" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startsAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_events_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_tags" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deviceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_device_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "scopeKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "periodStart", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_budget_periods_scope_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "runId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_calls_run_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "costSource", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "reconciledAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_calls_reconcile_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "callId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_cost_adjustments_call_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "runId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "budgetPeriodId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_reservations_run_period_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_reservations_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_user_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actionKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_action_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "leaseExpiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "idempotencyKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"idempotencyKey\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_idempotency_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "field", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_translation_sources_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "field", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_documents_doc_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_documents_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_documents_updated_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_drafts_key_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_drafts_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_drafts_updated_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "field", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_field_locks_key_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_field_locks_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_field_locks_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "revisionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_file_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_item_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_translation_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_language_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_actor_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "action", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_active_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scheduledFor", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_due_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_created_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_shared_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_locale_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastRun", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_cron_last_run_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_cron" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_dashboard_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_alt_file_language_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "fileFingerprint", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_alt_analysis_file_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "status <> 'completed'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_alt_state_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_name_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_words_lang_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tableName", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "pluginCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "variable", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_words_lookup_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_logs_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_logs_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "reportId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_report_events_report_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "targetType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "targetId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_reports_target_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_reports_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "assigneeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_reports_assignee_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "reporterId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_reports_reporter_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "scopeFamily", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scopeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_reports_scope_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_position_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "location", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_location_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_claim_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "idempotencyKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_idempotency_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "notificationId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "seq", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_notification_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"emailPending\" = true AND \"emailDeliveryId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_email_pending_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "unreadCount", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_user_state_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupBucket", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_group_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lastActivityAt", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archivedAt\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_inbox_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lastActivityAt", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archivedAt\" IS NULL AND \"readSeq\" < \"activitySeq\"", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "subjectType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "subjectId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_subject_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastActivityAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_last_activity_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "latestEventId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_latest_event_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "webauthnUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_webauthn_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_status_available_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "dedupeKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"dedupeKey\" IS NOT NULL AND status IN ('pending', 'processing')", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_dedupe_active_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_roles_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "search_vector", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_search_vector_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorIds", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_author_ids_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_item_type_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_language_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "isPublic", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_is_public_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "imageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_image_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deviceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_device_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ipAddress", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_known_devices_ip_address_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "avatarId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_avatar_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_cover_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_secondary_roles_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "providerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_user_provider_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_translations_language_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverImage", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_cover_image_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "friendlyUrl", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_friendly_url_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "syndicationPriority", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_syndication_priority_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_faq_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_tags_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_tags_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "code", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_code_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "animation", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_animation_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_gallery_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_gallery_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "primaryTag", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_primary_tag_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_gallery_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_gallery_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_related_events_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_related_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_related_events_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events_related_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_sessions_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_tags_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_tags_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_related_tags_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_related_tags_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_tags_translations" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_sessions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "deviceId" + ], + "schemaTo": "public", + "tableTo": "core_sessions_known_devices", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_calls_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": false, + "columns": [ + "callId" + ], + "schemaTo": "public", + "tableTo": "core_ai_calls", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_cost_adjustments_callId_core_ai_calls_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_cost_adjustments_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_reservations_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": false, + "columns": [ + "budgetPeriodId" + ], + "schemaTo": "public", + "tableTo": "core_ai_budget_periods", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_reservations_FClYpPXZwvsm_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_role_permissions_roleId_core_roles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_role_policies_roleId_core_roles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_ai_runs_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": false, + "columns": [ + "updatedById" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_ai_translation_sources_updatedById_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_user_overrides_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "nameExplicit": false, + "columns": [ + "updatedBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_documents_updatedBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": false, + "columns": [ + "updatedBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_drafts_updatedBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "core_content_field_locks_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": false, + "columns": [ + "revisionId" + ], + "schemaTo": "public", + "tableTo": "core_content_revisions", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "core_content_file_refs_5get6SfhBPHr_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "core_content_file_refs_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": false, + "columns": [ + "actorUserId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_revisions_actorUserId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": false, + "columns": [ + "createdBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_schedules_createdBy_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_dashboard_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "languageCode" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_languageCode_core_languages_code_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_alt_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "updatedById" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_alt_updatedById_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_analysis_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_alt_analysis_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_state_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "nameExplicit": false, + "columns": [ + "languageCode" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_languages_words_languageCode_core_languages_code_fk", + "entityType": "fks", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_logs_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": false, + "columns": [ + "reportId" + ], + "schemaTo": "public", + "tableTo": "core_moderation_reports", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderation_report_events_4CqHOxfheaUm_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "nameExplicit": false, + "columns": [ + "actorId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_moderation_report_events_actorId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "nameExplicit": false, + "columns": [ + "reporterId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_moderation_reports_reporterId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": false, + "columns": [ + "assigneeId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_moderation_reports_assigneeId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": false, + "columns": [ + "resolvedById" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_moderation_reports_resolvedById_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "parentId" + ], + "schemaTo": "public", + "tableTo": "core_navigation", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_navigation_parentId_core_navigation_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_deliveries_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": false, + "columns": [ + "eventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_notification_deliveries_vcf37BytIa0M_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": false, + "columns": [ + "actorId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_notification_events_actorId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": false, + "columns": [ + "eventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_UQ7ZEs0I6ijx_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "notificationId" + ], + "schemaTo": "public", + "tableTo": "core_notifications", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_Vxye2Cs6MgcL_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_user_state_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notifications_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": false, + "columns": [ + "latestEventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "core_notifications_I8QoGbTal5Wt_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkey_challenges_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkeys_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": false, + "columns": [ + "imageId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_search_index_imageId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_sessions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "deviceId" + ], + "schemaTo": "public", + "tableTo": "core_sessions_known_devices", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_sessions_deviceId_core_sessions_known_devices_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "core_users_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "language" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET DEFAULT", + "name": "core_users_language_core_languages_code_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "avatarId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_avatarId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "coverId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_coverId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_confirm_emails_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_forgot_password_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_secondary_roles_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_secondary_roles_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "avatarFileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_sso_avatarFileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_operations_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_profile_sources_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "nameExplicit": true, + "columns": [ + "userId", + "providerId" + ], + "schemaTo": "public", + "tableTo": "core_users_sso", + "columnsTo": [ + "userId", + "providerId" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_profile_sources_connection_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_categories_translations_itemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_categories_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "blog_posts_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": false, + "columns": [ + "coverImage" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_coverImage_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_relatedItemId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_category_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_category_id_relatedItemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_translations_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_categories_itemId_example_advanced_ar", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_categories_relatedItemId_example_cate", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_faq_itemId_example_advanced_articles_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_itemId_example_advan", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_relatedItemId_exampl", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_tags_ATchHLRtvoCM_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_tags_dEsWdkDmu7nW_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_translations_itemId_example_advanced_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_translations_languageId_core_language", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_articles_author_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "animation" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_animation_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_category_example_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_articles_gallery_itemId_example_articles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_gallery_relatedItemId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_events_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": false, + "columns": [ + "primaryTag" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_events_primaryTag_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_events_category_example_categories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_gallery_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_events_gallery_relatedItemId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_related_events_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_related_events" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_related_events_oaRvqnKyAS7L_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_related_events" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_sessions_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_tags_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_tags" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_events_tags_relatedItemId_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_translations_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_events_translations_languageId_core_languages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_translations" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_localized_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_localized_articles_translations_itemId_example_localize", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_localized_articles_translations_languageId_core_languag", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_tags_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_tags_category_example_categories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_tags_related_tags_itemId_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_tags_related_tags_relatedItemId_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_tags_translations_itemId_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_tags_translations_languageId_core_languages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags_translations" + }, + { + "columns": [ + "roleId", + "permission" + ], + "nameExplicit": false, + "name": "core_ai_role_permissions_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "columns": [ + "eventId", + "userId" + ], + "nameExplicit": false, + "name": "core_notification_receipts_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "columns": [ + "userId", + "roleId" + ], + "nameExplicit": false, + "name": "core_users_secondary_roles_userId_roleId_pk", + "entityType": "pks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "columns": [ + "userId", + "field" + ], + "nameExplicit": false, + "name": "core_users_sso_profile_sources_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_categories_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_author_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_category_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_posts_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_categories_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_related_articles_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_tags_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_articles_gallery_pk", + "entityType": "pks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_events_gallery_pk", + "entityType": "pks", + "schema": "public", + "table": "example_events_gallery" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_events_related_events_pk", + "entityType": "pks", + "schema": "public", + "table": "example_events_related_events" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_events_tags_pk", + "entityType": "pks", + "schema": "public", + "table": "example_events_tags" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_events_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_events_translations" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_localized_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_tags_related_tags_pk", + "entityType": "pks", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_tags_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_tags_translations" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_permissions_pkey", + "schema": "public", + "table": "core_admin_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_sessions_pkey", + "schema": "public", + "table": "core_admin_sessions", + "entityType": "pks" + }, + { + "columns": [ + "actionKey" + ], + "nameExplicit": false, + "name": "core_ai_action_settings_pkey", + "schema": "public", + "table": "core_ai_action_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_budget_periods_pkey", + "schema": "public", + "table": "core_ai_budget_periods", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_calls_pkey", + "schema": "public", + "table": "core_ai_calls", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_cost_adjustments_pkey", + "schema": "public", + "table": "core_ai_cost_adjustments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_reservations_pkey", + "schema": "public", + "table": "core_ai_reservations", + "entityType": "pks" + }, + { + "columns": [ + "roleId" + ], + "nameExplicit": false, + "name": "core_ai_role_policies_pkey", + "schema": "public", + "table": "core_ai_role_policies", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_runs_pkey", + "schema": "public", + "table": "core_ai_runs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_settings_pkey", + "schema": "public", + "table": "core_ai_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_translation_sources_pkey", + "schema": "public", + "table": "core_ai_translation_sources", + "entityType": "pks" + }, + { + "columns": [ + "userId" + ], + "nameExplicit": false, + "name": "core_ai_user_overrides_pkey", + "schema": "public", + "table": "core_ai_user_overrides", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_documents_pkey", + "schema": "public", + "table": "core_content_documents", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_drafts_pkey", + "schema": "public", + "table": "core_content_drafts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_field_locks_pkey", + "schema": "public", + "table": "core_content_field_locks", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_file_refs_pkey", + "schema": "public", + "table": "core_content_file_refs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_revisions_pkey", + "schema": "public", + "table": "core_content_revisions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_schedules_pkey", + "schema": "public", + "table": "core_content_schedules", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_slug_history_pkey", + "schema": "public", + "table": "core_content_slug_history", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_cron_pkey", + "schema": "public", + "table": "core_cron", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_dashboard_pkey", + "schema": "public", + "table": "core_admin_dashboard", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_pkey", + "schema": "public", + "table": "core_files", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_alt_pkey", + "schema": "public", + "table": "core_files_alt", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_alt_analysis_pkey", + "schema": "public", + "table": "core_files_alt_analysis", + "entityType": "pks" + }, + { + "columns": [ + "fileId" + ], + "nameExplicit": false, + "name": "core_files_alt_state_pkey", + "schema": "public", + "table": "core_files_alt_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_languages_pkey", + "schema": "public", + "table": "core_languages", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_languages_words_pkey", + "schema": "public", + "table": "core_languages_words", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_logs_pkey", + "schema": "public", + "table": "core_logs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderation_report_events_pkey", + "schema": "public", + "table": "core_moderation_report_events", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderation_reports_pkey", + "schema": "public", + "table": "core_moderation_reports", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderators_permissions_pkey", + "schema": "public", + "table": "core_moderators_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_navigation_pkey", + "schema": "public", + "table": "core_navigation", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_deliveries_pkey", + "schema": "public", + "table": "core_notification_deliveries", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_events_pkey", + "schema": "public", + "table": "core_notification_events", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "core_notification_settings_pkey", + "schema": "public", + "table": "core_notification_settings", + "entityType": "pks" + }, + { + "columns": [ + "userId" + ], + "nameExplicit": false, + "name": "core_notification_user_state_pkey", + "schema": "public", + "table": "core_notification_user_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notifications_pkey", + "schema": "public", + "table": "core_notifications", + "entityType": "pks" + }, + { + "columns": [ + "pageId" + ], + "nameExplicit": false, + "name": "core_page_layouts_pkey", + "schema": "public", + "table": "core_page_layouts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkey_challenges_pkey", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkeys_pkey", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_queue_pkey", + "schema": "public", + "table": "core_queue", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_roles_pkey", + "schema": "public", + "table": "core_roles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_search_index_pkey", + "schema": "public", + "table": "core_search_index", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "core_secrets_pkey", + "schema": "public", + "table": "core_secrets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_pkey", + "schema": "public", + "table": "core_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_known_devices_pkey", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_pkey", + "schema": "public", + "table": "core_users", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_confirm_emails_pkey", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_forgot_password_pkey", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_sso_operations_pkey", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_categories_pkey", + "schema": "public", + "table": "blog_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_posts_pkey", + "schema": "public", + "table": "blog_posts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_pkey", + "schema": "public", + "table": "example_advanced_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_faq_pkey", + "schema": "public", + "table": "example_advanced_articles_faq", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_articles_pkey", + "schema": "public", + "table": "example_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_categories_pkey", + "schema": "public", + "table": "example_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_events_pkey", + "schema": "public", + "table": "example_events", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_events_sessions_pkey", + "schema": "public", + "table": "example_events_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_localized_articles_pkey", + "schema": "public", + "table": "example_localized_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_pages_pkey", + "schema": "public", + "table": "example_pages", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_tags_pkey", + "schema": "public", + "table": "example_tags", + "entityType": "pks" + }, + { + "nameExplicit": true, + "columns": [ + "itemType", + "itemId", + "languageCode" + ], + "nullsNotDistinct": false, + "name": "core_search_index_item_unique", + "entityType": "uniques", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + "providerId", + "providerAccountId" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_provider_account_key", + "entityType": "uniques", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + "userId", + "providerId" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_user_provider_key", + "entityType": "uniques", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_admin_sessions_token_unique", + "schema": "public", + "table": "core_admin_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_admin_dashboard_userId_unique", + "schema": "public", + "table": "core_admin_dashboard", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "key" + ], + "nullsNotDistinct": false, + "name": "core_files_key_unique", + "schema": "public", + "table": "core_files", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "code" + ], + "nullsNotDistinct": false, + "name": "core_languages_code_unique", + "schema": "public", + "table": "core_languages", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "openKey" + ], + "nullsNotDistinct": false, + "name": "core_moderation_reports_openKey_key", + "schema": "public", + "table": "core_moderation_reports", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "idempotencyKey" + ], + "nullsNotDistinct": false, + "name": "core_notification_deliveries_idempotencyKey_key", + "schema": "public", + "table": "core_notification_deliveries", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_passkey_challenges_tokenHash_key", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "credentialId" + ], + "nullsNotDistinct": false, + "name": "core_users_passkeys_credentialId_key", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_sessions_token_unique", + "schema": "public", + "table": "core_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "publicId" + ], + "nullsNotDistinct": false, + "name": "core_sessions_known_devices_publicId_unique", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "nameCode" + ], + "nullsNotDistinct": false, + "name": "core_users_nameCode_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "name" + ], + "nullsNotDistinct": false, + "name": "core_users_name_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "email" + ], + "nullsNotDistinct": false, + "name": "core_users_email_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_confirm_emails_token_unique", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_userId_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_token_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_operations_tokenHash_key", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "uniques" + }, + { + "value": "\"intent\" IN ('link', 'import', 'sync', 'preview')", + "name": "core_users_sso_operations_intent_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "value": "\"field\" IN ('avatar', 'firstName', 'lastName')", + "name": "core_users_sso_profile_sources_field_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_profile_sources" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/apps/web/content/docs/content-engine/content-visibility.mdx b/apps/web/content/docs/content-engine/content-visibility.mdx index aeabf6bda4..06abc5dded 100644 --- a/apps/web/content/docs/content-engine/content-visibility.mdx +++ b/apps/web/content/docs/content-engine/content-visibility.mdx @@ -126,12 +126,13 @@ A record is public only when it is published, its `publishedAt` is not in the fu ## Who can still see it -Hidden doesn't mean gone for the people working on it. Signed in, a record's **authors** and its **editors** still reach it: +Hidden doesn't mean gone for the people working on it. Signed in, a record's **authors**, its **editors** and its **moderators** still reach it: - **Authors** are the users in the content type's [`search.authorField`](/docs/content-engine/search), such as the blog's **Author** field. - **Editors** are staff with `can_edit` on the content type, such as `posts:can_edit` for the blog. +- **Moderators** with the moderator permission `can_view_hidden` on a [reportable](/docs/dev/moderation/enable-reports) content type, in the record's scope when it has one. They need no AdminCP permission. -The route tells whose record it is by its `id`, so `id` must be in `publicApi.fields`. For authors and editors: +The route tells whose record it is by its `id`, so `id` must be in `publicApi.fields`. For them: - The page opens with an eye-off icon before the title and a **This article is hidden** note, so nobody mistakes it for a live page. The response sends `noindex` and `Cache-Control: private, no-store`, so search engines and caches never keep it. - The detail route answers `200`, and `GET .../delivery/resolve/{slug}` returns `hidden: true`. diff --git a/apps/web/content/docs/content-engine/reference.mdx b/apps/web/content/docs/content-engine/reference.mdx index 2d1490eae4..9db162300e 100644 --- a/apps/web/content/docs/content-engine/reference.mdx +++ b/apps/web/content/docs/content-engine/reference.mdx @@ -66,6 +66,7 @@ Many of these rules are type errors too, so your editor often complains before t | `search` | [`search`](#search) | No | off | Site search indexing | | `visibility` | `boolean` | No | off | [Hide and unhide](#visibility) records. Needs `publication` | | `duplication` | `true` \| [`duplication`](#duplication) | No | off | A [Duplicate](#duplication) action that copies a record into a new draft | +| `reports` | `true` \| [`reports`](#reports) | No | off | Readers can [report](#reports) published records to moderators. Needs `publicApi` | | `indexes` | [`indexes`](#indexes) | No | `[]` | Extra database indexes | A feature block is on when it is present. `publicApi`, `localization` and `search` take an options object. `publication`, `liveEditing` and `visibility` are `true`, and `editorial`, `delivery` and `duplication` take `true` or an options object. Leave a block out, or set it to `false`, to keep it off. Write `true` or the object directly rather than a `boolean` variable, because the generated types read the literal to decide which columns and routes exist. @@ -277,6 +278,15 @@ publication: true, visibility: true, ``` +## `reports` + +`reports: true` lets signed-in readers report a published record. It registers a report target, adds `can_view_reports` and `can_manage_reports` to the moderator catalog, plus `can_view_hidden` and `can_hide` with `visibility`, and needs no migration. Pass `{ titleField, snapshotFields }` to choose what the report's snapshot keeps. Guide: [Enable reports](/docs/dev/moderation/enable-reports). Options: [Moderation reference](/docs/dev/moderation/reference#reports-on-a-content-type). + +```ts title="plugins/blog/src/content/post.ts" +publicApi: { path: "blog", fields: ["id", "title", "friendlyUrl", "content"] }, +reports: true, +``` + ## `duplication` Adds a **Duplicate** action and the `POST /{id}/duplicate` staff route, which copy a record and its translations into a new draft. Pass `true`, or an object to change the title suffix. Guide: [Duplicate content](/docs/content-engine/duplicate-content). diff --git a/apps/web/content/docs/dev/events/built-in-events.mdx b/apps/web/content/docs/dev/events/built-in-events.mdx index c18b93c20c..98a8e258d5 100644 --- a/apps/web/content/docs/dev/events/built-in-events.mdx +++ b/apps/web/content/docs/dev/events/built-in-events.mdx @@ -536,6 +536,32 @@ own preferences are saved, by them or by staff on their AdminCP user page. pauses, deletes or resets everything, or note when staff changed how a single member is notified. +## Moderation events + +Seven names, declared in `VitNodeEvents` in +`packages/vitnode/src/api/models/events.ts` and emitted by core with +`pluginId: "@vitnode/core"`. Each fires **after** its database transaction +commits. The envelope's `actor` is the reporter for `created` and the +moderator for the rest. Adding an internal note emits nothing. See +[Moderation](/docs/dev/moderation). + +| Event | Payload | Fires when | +| ---------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------- | +| `moderation.report.created` | `{ reportId, pluginId, targetType, targetId, reason }` | A signed-in user files a report | +| `moderation.report.assigned` | `{ reportId, assigneeId, previousAssigneeId }` | A moderator takes, reassigns or unassigns a report. `assigneeId` may be `null` | +| `moderation.report.action` | `{ reportId, action, pluginId, targetType, targetId }` | A content action from the ModCP changed the content. `action` is `hide` or `unhide` | +| `moderation.report.resolved` | `{ reportId, resolution }` | A report is closed with an outcome | +| `moderation.report.reopened` | `{ reportId }` | A closed report is reopened | +| `moderation.report.note_updated` | `{ reportId, noteId }` | A moderator edits an internal note | +| `moderation.report.note_deleted` | `{ reportId, noteId }` | A moderator deletes an internal note | + +A hide or restore from the ModCP also emits the content type's own +`content.{id}.hidden` or `unhidden` event, because it goes through the same +visibility service as the AdminCP. + +**A listener would** notify a staff channel when a report arrives, or keep a +per-moderator count of resolved reports. + ## AI events Declared in `VitNodeEvents` in `packages/vitnode/src/api/models/events.ts`. diff --git a/apps/web/content/docs/dev/meta.json b/apps/web/content/docs/dev/meta.json index 91ff1b562c..2e90a52dce 100644 --- a/apps/web/content/docs/dev/meta.json +++ b/apps/web/content/docs/dev/meta.json @@ -24,6 +24,7 @@ "---Extend VitNode---", "widgets", "working-with-users", + "moderation", "events", "notifications", "---Services---", diff --git a/apps/web/content/docs/dev/moderation/configure-moderators.mdx b/apps/web/content/docs/dev/moderation/configure-moderators.mdx new file mode 100644 index 0000000000..5f6bbc8c41 --- /dev/null +++ b/apps/web/content/docs/dev/moderation/configure-moderators.mdx @@ -0,0 +1,86 @@ +--- +title: Configure moderators +description: Give a VitNode user or role moderator permissions in the AdminCP, and limit report permissions to selected resources such as categories. +icon: UserCog +--- + +import { ImgDocs } from "@/components/fumadocs/img"; +import scopeEditor from "./moderation-scope-editor.webp"; +import scopeDialog from "./moderation-scope-dialog.webp"; + +You set up moderators on the same AdminCP screen as every other staff permission. This guide makes a user a moderator who handles blog reports everywhere and example article reports only in some categories. + +## Before you begin + +You need the `staff_moderators` admin permissions **Create moderators** and **Edit moderator permissions**, or a root role. The content types you want moderated must have [reports enabled](/docs/dev/moderation/enable-reports). + + + + + +### Add the moderator + +Open **AdminCP → Staff → Moderators** and choose **Add Moderator**. Pick a role or a single user and save. The new entry grants nothing yet, and the AdminCP opens its permissions. + + + + + +### Grant report permissions + +Keep **Restricted** selected. Pick a plugin on the left, open a module, and tick the permissions. For the blog, open **Articles** and tick **View article reports**, **Claim, assign and resolve article reports**, **Open hidden articles** and **Hide and unhide articles**. + +A permission that depends on another one stays locked until its dependency is ticked. **Hide and unhide articles** needs **Open hidden articles**, so a moderator can always see what they hid. + + + + + +### Choose where a scoped permission applies + +A scoped permission shows its scope under the checkbox: **All resources**, or the number of selected resources. Here the example plugin's **View reports** covers two categories and the management permission covers one: + + + +Choose **Change** to open the picker. **All resources** includes resources created later. **Selected resources** lists the family's resources with a search box, loading more on demand: + + + +Tick **Apply to every scoped permission in this module** to copy the same scope to the module's other scoped permissions. Each permission still saves its own scope, so you can narrow one afterwards. + + + + + +### Save + +Choose **Save changes**. The moderator's permissions apply on their next request. If a resource no longer exists, the picker marks it **Removed**. Take it out before saving. + + + + + +## Check the result + +Sign in as the moderator. The user menu now has a **Moderator CP** link to `/modcp/reports`, and the reports board shows blog reports plus example article reports from the two selected categories only. See [Process reports](/docs/dev/moderation/process-reports). + +## When saving is refused + +The form checks scopes before it sends them, and the API checks them again. Each problem is shown under the permission it belongs to: + +| Message | What to change | +| ---------------------------------------------------------- | -------------------------------------------------------------- | +| Choose where *permission* applies | Open **Change** and pick **All resources** or some resources | +| Pick at least one resource for *permission* | A selection cannot be empty | +| *Permission* reaches further than *dependency* | Narrow the permission, or widen its dependency first | +| *Permission* names resources that no longer exist | Remove the resources marked **Removed** | + +You can only grant what you hold yourself. An administrator whose own moderator permissions cover two categories cannot give a third one, or **All resources**, to somebody else. Only a root administrator can grant **Unrestricted**. Changing a role's permissions or a user's roles takes effect immediately. VitNode clears the cached permissions of every affected user. diff --git a/apps/web/content/docs/dev/moderation/enable-reports.mdx b/apps/web/content/docs/dev/moderation/enable-reports.mdx new file mode 100644 index 0000000000..7050c41292 --- /dev/null +++ b/apps/web/content/docs/dev/moderation/enable-reports.mdx @@ -0,0 +1,120 @@ +--- +title: Enable reports +description: Let signed-in readers report a VitNode Content Engine content type, such as blog articles, with one option and a Report button. +icon: Flag +--- + +import { ImgDocs } from "@/components/fumadocs/img"; +import reportDialog from "./moderation-report-dialog.webp"; + +Turn on reports for a content type so signed-in readers can flag a published record to your moderators. This guide follows the blog plugin, whose articles are reportable. + +## Before you begin + +The content type needs a [public API](/docs/content-engine/public-api). A reader can only report what the public API lets them read, so a draft, a hidden record or an unpublished translation can never be reported. + + + + + +### Turn on reports + +Add `reports: true` to the content type definition: + +```ts title="plugins/blog/src/content/post.ts" +export const blogPostContentType = defineContentType({ + id: "blog.post", + tableName: "blog_posts", + publication: true, + visibility: true, + reports: true, // [!code ++] + // ... +}); +``` + +`reports` needs no migration. Reports live in core tables, and the content type's own tables do not change. + + + + + +### Choose what the snapshot keeps (optional) + +By default, a report's snapshot keeps the record's title and its public `text`, `textarea`, `richText`, `enum` and `file` fields. The title comes from `admin.titleField` when that field is public. Pass an object to pick the fields yourself: + +```ts title="plugins/blog/src/content/post.ts" +reports: { + titleField: "title", + snapshotFields: ["content", "excerpt"], +}, +``` + +An unknown field, or a field of a kind a snapshot cannot hold, such as a relation, throws when the content type is defined. Fields you list explicitly may be private, because only moderators read snapshots. + + + + + +### Label the generated permissions + +`reports: true` adds moderator permissions to the content type's permission module: `can_view_reports`, `can_manage_reports`, `can_edit_notes` and `can_delete_notes`, plus `can_view_hidden` and `can_hide` when the content type has [`visibility`](/docs/content-engine/content-visibility). Core ships generic labels, such as **View reports**. Override them in your plugin when you want your own wording: + +```json title="plugins/blog/src/locales/en.json" +{ + "@vitnode/blog:posts:can_view_reports": "View article reports", + "@vitnode/blog:posts:can_manage_reports": "Claim, assign and resolve article reports", + "@vitnode/blog:posts:can_view_hidden": "Open hidden articles" +} +``` + +Enabling reports grants nothing. An administrator still has to give these permissions to a moderator. See [Configure moderators](/docs/dev/moderation/configure-moderators). + + + + + +### Place the Report button + +A plugin's own pages place the button themselves. Pass the record's stable id and the language the reader was served: + +```tsx title="plugins/blog/src/pages/post-page.tsx" +import { ReportButton } from "@vitnode/core/tanstack/moderation"; + +{!metadata.hidden && metadata.itemId !== null ? ( + +) : null} +``` + +The blog reads `itemId`, `locale` and `requestedLocale` from the [delivery resolution](/docs/content-engine/public-pages), so the button reports the article in the language the reader actually saw, even after a fallback. The form loads only when the dialog opens. A signed-out reader gets a **Sign in** link instead of the form. + + + + + +## Check the result + +Rebuild with `pnpm build:plugins`, restart `pnpm dev`, sign in as a regular user and open a published article. Click **Report**, pick a reason and send it: + + + +A toast says **Report sent**. Try sending it again and the toast tells you, politely, that you already did. A moderator with `can_view_reports` on the blog now sees the report in [the ModCP](/docs/dev/moderation/process-reports). + +## What the server checks + +- The reporter must be signed in. A guest gets `401`. +- The target must be readable by the reporter through the public API. Anything else is `404`, the same answer as a typo. +- **Other** needs at least 10 characters of details. Details are at most 2000 characters. +- One reporter can hold one open report per target. Other reporters get their own reports. +- A user can file at most 20 reports per hour, on top of the per-IP [rate limiter](/docs/dev/advanced/rate-limiter) every API route has. + +Reports work for targets outside the Content Engine too. A plugin registers its own target with `defineReportTarget`. See the [reference](/docs/dev/moderation/reference#custom-report-targets). diff --git a/apps/web/content/docs/dev/moderation/index.mdx b/apps/web/content/docs/dev/moderation/index.mdx new file mode 100644 index 0000000000..f701b79810 --- /dev/null +++ b/apps/web/content/docs/dev/moderation/index.mdx @@ -0,0 +1,38 @@ +--- +title: Moderation +description: How VitNode moderation works. Users report content, the server keeps a snapshot, and moderators handle reports in the public Moderator CP with permissions you can limit to some resources. +icon: ShieldAlert +--- + +VitNode moderation lets signed-in users report content, and lets moderators review those reports and act on the content from the public site. It is built from three parts that core provides and any plugin can use: reports, the **Moderator CP** (ModCP) and scoped moderator permissions. + +| Part | What it does | +| -------------------- | -------------------------------------------------------------------------------------------------------------- | +| Reports | A report stores the reason, the reporter's details and a server-side snapshot of what the reporter saw | +| Moderator CP | `/modcp/reports` on the public site: a reports board, the snapshot, assignment, notes, resolution and content actions | +| Scoped permissions | A moderator permission can cover every resource of a family, such as every forum section, or only some of them | + +A moderator signs in with their normal user session. The ModCP never asks for an AdminCP session, and the server authorizes every ModCP request. + +## Choose a guide + +| You want to | Read | +| --------------------------------------------------------------- | ------------------------------------------------------------------- | +| Let readers report a Content Engine content type, like the blog | [Enable reports](/docs/dev/moderation/enable-reports) | +| Limit moderators to some categories or sections | [Scoped permissions](/docs/dev/moderation/scoped-permissions) | +| Give a user or role moderator permissions in the AdminCP | [Configure moderators](/docs/dev/moderation/configure-moderators) | +| Review, assign, resolve and act on reports | [Process reports](/docs/dev/moderation/process-reports) | +| Look up routes, options, events and limits | [Moderation reference](/docs/dev/moderation/reference) | + +## How the parts fit together + +1. A content type opts in with `reports: true`. This registers a **report target** and adds report permissions to the moderator catalog. Nobody holds them until an administrator grants them. +2. A reader clicks **Report** on the article page. The browser sends only the article id, the language, the reason and the details. The server checks that the reader can read the article, then stores a snapshot of it. +3. A moderator opens the ModCP. The reports board shows only reports the moderator may view, filtered before anything is counted. +4. The moderator takes the report, writes internal notes, hides the article if needed and closes the report with an outcome. Every step lands in the report's timeline. + +Reports belong to core, not to the plugin. They keep their snapshot after the article is edited, after its revisions are pruned, after it is deleted and even after its plugin is uninstalled. + +## What is not included yet + +This first version covers reports plus hiding and restoring content. It has no warnings, bans, approval queues, automatic moderation, anonymous reports, report notifications or permanent deletion from the ModCP. Moderators still have to read things themselves, sorry. diff --git a/apps/web/content/docs/dev/moderation/meta.json b/apps/web/content/docs/dev/moderation/meta.json new file mode 100644 index 0000000000..a591b75a1a --- /dev/null +++ b/apps/web/content/docs/dev/moderation/meta.json @@ -0,0 +1,13 @@ +{ + "title": "Moderation", + "description": "Reports, the Moderator CP and scoped moderator permissions", + "icon": "ShieldAlert", + "pages": [ + "index", + "enable-reports", + "scoped-permissions", + "configure-moderators", + "process-reports", + "reference" + ] +} diff --git a/apps/web/content/docs/dev/moderation/moderation-modcp-queue.webp b/apps/web/content/docs/dev/moderation/moderation-modcp-queue.webp new file mode 100644 index 0000000000..6391ba8b06 Binary files /dev/null and b/apps/web/content/docs/dev/moderation/moderation-modcp-queue.webp differ diff --git a/apps/web/content/docs/dev/moderation/moderation-modcp-report.webp b/apps/web/content/docs/dev/moderation/moderation-modcp-report.webp new file mode 100644 index 0000000000..459bce64c1 Binary files /dev/null and b/apps/web/content/docs/dev/moderation/moderation-modcp-report.webp differ diff --git a/apps/web/content/docs/dev/moderation/moderation-report-dialog.webp b/apps/web/content/docs/dev/moderation/moderation-report-dialog.webp new file mode 100644 index 0000000000..b93f9ef75e Binary files /dev/null and b/apps/web/content/docs/dev/moderation/moderation-report-dialog.webp differ diff --git a/apps/web/content/docs/dev/moderation/moderation-scope-dialog.webp b/apps/web/content/docs/dev/moderation/moderation-scope-dialog.webp new file mode 100644 index 0000000000..586b90efaa Binary files /dev/null and b/apps/web/content/docs/dev/moderation/moderation-scope-dialog.webp differ diff --git a/apps/web/content/docs/dev/moderation/moderation-scope-editor.webp b/apps/web/content/docs/dev/moderation/moderation-scope-editor.webp new file mode 100644 index 0000000000..e3a0aaf48f Binary files /dev/null and b/apps/web/content/docs/dev/moderation/moderation-scope-editor.webp differ diff --git a/apps/web/content/docs/dev/moderation/process-reports.mdx b/apps/web/content/docs/dev/moderation/process-reports.mdx new file mode 100644 index 0000000000..100af5ec6c --- /dev/null +++ b/apps/web/content/docs/dev/moderation/process-reports.mdx @@ -0,0 +1,118 @@ +--- +title: Process reports +description: Review VitNode reports in the Moderator CP. Work the reports board, compare the snapshot with the live content, take a report, hide content and close it with an outcome. +icon: Inbox +--- + +import { ImgDocs } from "@/components/fumadocs/img"; +import queue from "./moderation-modcp-queue.webp"; +import detail from "./moderation-modcp-report.webp"; + +The **Moderator CP** at `/modcp/reports` is where moderators work through reports, signed in with their normal account. This guide takes a spam report about a blog article from new to closed. + +## Before you begin + +You need a moderator entry with `can_view_reports` for the content type, and `can_manage_reports` to change anything. Hiding needs `can_hide`. See [Configure moderators](/docs/dev/moderation/configure-moderators). + + + + + +### Open the board + +Choose **Moderator CP** in the user menu. **Reports** is a board with three columns, **New**, **In progress** and **Done**. It holds the reports you may view, newest first in each column: + + + +Search by title or report number above the board. Type at least three characters, like `#12` or `gift`. **Assigned** narrows the board to your reports or the unassigned ones. + +Search and the filter live in the address, so you can send a filtered board to another moderator as a link. Each column loads 20 cards at a time, and **Show more** loads the next 20. + +The pill next to the reason, like **+1**, means other people reported the same content. It only counts reports you may view. Reports outside your scopes never appear, not even in the column counts. + +Drag a card to another column to move it, and the column tells you what will happen before you let go: + +| Move | What happens | +| --- | --- | +| **New** → **In progress** | The report is assigned to you. | +| **In progress** → **New** | The assignee is removed. | +| Any column → **Done** | The close dialog opens, so you pick an outcome first. | +| **Done** → **New** or **In progress** | The reopen dialog asks for a reason. | + +The card moves right away. If another moderator changed the report first, you get a message and the card slides back to where the server says it belongs. Cards always sit in date order, so a moved card lands where its date puts it, not where you dropped it. + +On a touch screen, press and hold a card to pick it up. With a keyboard, focus the grip next to a card's **…** menu, press `Space`, use the arrow keys to pick a column and press `Space` again. The **…** menu has the same moves under **Move to**. + + + + + +### Read the report + +Open a report. The heading names the content type and its author. The main card starts with the **Description**, the reporter's own words, followed by the **Snapshot**, which is what the reporter saw at the moment they filed it. Choose **View full snapshot** to read it in a side panel. It never changes, even if the article was edited, its revisions were pruned or it was deleted. + +On the right, **Details** opens with the reported content: its type, its title linked to the live page and the plugin it belongs to. Below are the assignee, the reporter and the author with their role colors, the reason and the **Visibility** of the content right now. **Linked reports** lists other reports about the same content, each with its reporter and the start of their text. + + + +The snapshot names the language it was captured in. When the reader asked for another language and was served a fallback, it shows both. Files appear as names and sizes only. The report never copies the file itself. + + + + + +### Take the report + +Drag the card from **New** to **In progress**, or open the report and choose **Start progress** in the status button (**New**), or **Assign to me** under the assignee. The report is assigned to you and moves to **In progress**. To hand it over, open the assignee and pick another moderator; the list shows only moderators who may manage this report. **Stop progress** unassigns you and moves it back to **New**. + +When two moderators take the same report at once, one wins and the other sees **Another moderator took this report first**. Every change carries the version you loaded, so acting on a stale page refreshes it instead of overwriting someone else's work. + + + + + +### Note and act + +Under **Activity**, choose **Add an internal note** or press `M`. Notes use the rich-text editor, and `Cmd+Enter` (`Ctrl+Enter` on Windows) saves them. They are visible to moderators only, never to the reporter or the author. **Notes** is the tab that opens first; **All** and **History** show the rest of the activity. + +To change a note, open its **…** menu and choose **Edit** or **Delete**. You can always edit and delete your own notes. Editing or deleting another moderator's notes needs `can_edit_notes` or `can_delete_notes`. An edited note shows **(edited)**, and **History** records every edit with the text before and after, and every deletion with the removed text. + +Open **Actions** next to the status button, choose **Hide content** and confirm. The article leaves the public site, search and the sitemap at once, exactly like [hiding it from the AdminCP](/docs/content-engine/content-visibility). It gets a revision, the `hidden` event fires and VitNode revalidates the caches. The activity records the action only when it succeeds. **Restore content** brings it back. + + + + + +### Close the report + +Drag the card to **Done**, or open the status button on the report and choose **Close report**. Pick an outcome, **No violation**, **Action taken** or **Duplicate**, and add an explanation for the other moderators if you like. When you already hid the content, **Action taken** is picked for you. **Close report** moves it to **Done**. Other reports about the same content stay open, because each reporter's report closes on its own. + +On a closed report, the status button offers **Reopen**, which needs a reason that goes into the activity. Dragging the card out of **Done** asks for the same reason. + + + + + +## Check the result + +The report sits in the **Done** column, and its activity lists the report, the assignment, your note, the hide and the resolution with names and times. Open the article signed out: it answers `404` while hidden. Open it as the moderator: it loads with the hidden notice, because `can_view_hidden` lets moderators inspect hidden content without any AdminCP edit permission. + +## Who sees what + +- **Reporters** see only their own toast. Nothing about the report is public. +- **Moderators** with `can_view_reports` see the report, the reporter's name, the snapshot and the activity, for content in their scopes. Without it, a direct URL answers like a report that does not exist. +- **Moderators** with `can_manage_reports` can also take, reassign, note, resolve and reopen, and edit or delete their own notes. +- **Moderators** with `can_edit_notes` or `can_delete_notes` can edit or delete any moderator's notes. +- **Authors** of the reported content never learn who reported it. + +When content moves to a resource outside a moderator's scope, the report moves with it. The moderator loses access, and if the report was assigned to them, VitNode unassigns it and logs that in the activity. When the content is deleted, the report keeps its last known scope. diff --git a/apps/web/content/docs/dev/moderation/reference.mdx b/apps/web/content/docs/dev/moderation/reference.mdx new file mode 100644 index 0000000000..f22cb421fc --- /dev/null +++ b/apps/web/content/docs/dev/moderation/reference.mdx @@ -0,0 +1,164 @@ +--- +title: Moderation reference +description: Reference for VitNode moderation, covering the reports option, scope families, report targets, permissions, ModCP API routes, statuses, events, snapshot format and limits. +icon: BookOpen +--- + +Exact names, types and behavior of the moderation APIs. For step-by-step guides, start at [Moderation](/docs/dev/moderation). + +## `reports` on a content type + +`defineContentType({ reports })` from `@vitnode/core/content`. Omitted or `false` registers nothing. + +| Name | Type | Required | Default | Description | +| ---------------- | ------------- | -------- | ------------------------------------------------------------------- | ------------------------------------------------- | +| `titleField` | field name | No | `admin.titleField` when it is in `publicApi.fields`, else none | A `text`, `textarea` or `slug` field | +| `snapshotFields` | field names | No | Public `text`, `textarea`, `richText`, `enum` and `file` fields, without the title | Fields of kind `text`, `textarea`, `richText`, `slug`, `number`, `boolean`, `enum`, `dateTime` or `file` | + +`reports: true` uses both defaults. It throws without `publicApi`, for an unknown option, for `reports.enabled`, and for a field that does not exist or cannot be snapshotted. + +The server-side model can scope reports with `createContentModel(definition, { moderationScope: { column, family } })`, where `column` is a base-table column holding the resource id. + +## Generated moderator permissions + +Added to the content type's `permissionModule` in the **moderator** catalog, unless the plugin declares that moderator module itself. All of them carry the target's scope family when it has one. + +| Permission | Depends on | Added when | Allows | +| -------------------- | ------------------ | ---------------------------- | --------------------------------------------------- | +| `can_view_reports` | | Always | The reports board, report details and related reports | +| `can_manage_reports` | `can_view_reports` | Always | Take, reassign, notes, resolve, reopen; edit and delete your own notes | +| `can_edit_notes` | `can_manage_reports` | Always | Edit any moderator's notes | +| `can_delete_notes` | `can_manage_reports` | Always | Delete any moderator's notes | +| `can_view_hidden` | | The target can be hidden | Opening hidden content on the public site | +| `can_hide` | `can_view_hidden` | The target can be hidden | **Hide content** and **Restore content** in the ModCP | + +Content actions also need `can_manage_reports`, because they are recorded in the report. + +## Scoped grants + +A stored grant is `{ plugin, module, permission, scope? }`. `scope` is one of: + +```ts +{ family: "example.category", mode: "all" } +{ family: "example.category", mode: "selected", ids: ["1", "2"] } +``` + +Declare a scoped permission with `{ permission, dependsOn?, scope: "family.id" }` in `permissionStaff.moderator`. Admin permissions cannot be scoped. Resource ids match `/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/`. + +`PATCH /api/@vitnode/core/admin/staff/entry/moderator/{id}` answers `400` with `{ error: "invalid_permissions", issues }`. Each issue has a `key` and a `type`: `missing_scope`, `empty_selection`, `invalid_resource`, `unknown_resource`, `unexpected_scope`, `wrong_family` or `dependency_scope`. A grant wider than the caller's own permissions is `403`. + +`GET /api/@vitnode/core/users/permissions` returns the signed-in user's moderator grants, each with its `scope`. + +## `defineModerationScope` + +From `@vitnode/core/api/moderation`. Register with `buildApiPlugin({ moderation: { scopes } })`. + +| Name | Type | Required | Description | +| ------------- | --------------------------------------------------------------------------------- | -------- | ------------------------------------------------- | +| `id` | `string` | Yes | Lowercase and dot separated, unique across plugins | +| `label` | `string` | Yes | i18n key | +| `description` | `string` | No | i18n key shown in the picker | +| `list` | `(c, { cursor, limit, search }) => Promise<{ items, nextCursor }>` | Yes | One page of resources for the AdminCP picker | +| `find` | `(c, ids) => Promise` | Yes | The resources with these ids that exist | + +An item is `{ id: string; label: string; context?: string[] }`. The AdminCP reads them through `GET /api/@vitnode/core/admin/staff/scope-resources/{family}?search=&cursor=`, or `?ids=1,2` to label a saved selection, which also returns `missing` ids. + +## Custom report targets + +Content Engine types register their target automatically. Anything else registers one with `defineReportTarget` and `buildApiPlugin({ moderation: { reportTargets } })`. + +| Name | Type | Required | Description | +| ------------------ | ------------------------------------------------------------------- | -------- | --------------------------------------------------------------------------- | +| `type` | `string` | Yes | Stable id stored in reports, such as `forum.topic` | +| `label` | `string` | Yes | i18n key for the kind of thing, an ICU plural called with `count: 1` | +| `permissionModule` | `string` | Yes | Moderator module the report permissions are added to | +| `parseId` | `(value) => string \| null` | Yes | Validates a client id before anything reads it | +| `capture` | `(c, { targetId, locale }) => Promise` | Yes | The snapshot body, or `null` when the reporter cannot read the target | +| `current` | `(c, { targetId, locale }) => Promise<{ hidden, url } \| null>` | Yes | The target now, or `null` once deleted | +| `scope` | `string` | No | Scope family id | +| `resolveScope` | `(c, targetId) => Promise` | With `scope` | The resource id now, `""` for none, `undefined` once deleted | +| `actions` | `{ hide?, unhide? }` | No | `{ permission, available(state), run(c, { actorUserId, targetId, tx }) }` | + +`run` executes inside the report's transaction and returns `{ changed, effects }`. `effects` runs after the commit. When `run` throws, the transaction rolls back and no timeline entry is written. + +## Report lifecycle + +| Status | Reached by | +| ------------- | -------------------------------------------------- | +| `new` | Filing a report, unassigning, or reopening an unassigned report | +| `in_progress` | Taking or assigning a report, or reopening an assigned one | +| `done` | Resolving with `no_violation`, `action_taken` or `duplicate` | + +Reasons are `spam`, `abusive`, `rule_violation` and `other`; `other` needs details. Every change except a note needs `expectedVersion`. A stale version answers `409` with `{ error: "conflict", current }`, and taking a report someone else holds answers `409 already_claimed`. + +## ModCP API routes + +All under `/api/@vitnode/core/moderation`, using the user session. A guest gets `401`, a user without a moderator entry `403`, and a report outside the moderator's scopes `404`. + +| Method and path | Permission | Body | +| ------------------------------------ | -------------------- | -------------------------------------------- | +| `POST /reports` | Signed in | `{ pluginId, targetType, targetId, locale?, requestedLocale?, reason, description? }` | +| `GET /reports` | `can_view_reports` | Query `status`, `assigned`, `search`, `page`, `pageSize` | +| `GET /reports/{id}` | `can_view_reports` | | +| `POST /reports/{id}/claim` | `can_manage_reports` | `{ expectedVersion }` | +| `POST /reports/{id}/assign` | `can_manage_reports` | `{ expectedVersion, assigneeId }` | +| `POST /reports/{id}/notes` | `can_manage_reports` | `{ body }`: plain text or a rich-text document | +| `POST /reports/{id}/notes/{noteId}/edit` | `can_manage_reports` and the note's author, or `can_edit_notes` | `{ body }` | +| `POST /reports/{id}/notes/{noteId}/delete` | `can_manage_reports` and the note's author, or `can_delete_notes` | | +| `POST /reports/{id}/resolve` | `can_manage_reports` | `{ expectedVersion, resolution, explanation? }` | +| `POST /reports/{id}/reopen` | `can_manage_reports` | `{ expectedVersion, reason }` | +| `POST /reports/{id}/actions/{action}` | `can_manage_reports` and the action's permission | `{ expectedVersion }` | +| `GET /reports/{id}/assignees` | `can_manage_reports` | | + +## Events + +| Event | Payload | +| ----------------------------- | ------------------------------------------------------------- | +| `moderation.report.created` | `{ reportId, pluginId, targetType, targetId, reason }` | +| `moderation.report.assigned` | `{ reportId, assigneeId, previousAssigneeId }` | +| `moderation.report.action` | `{ reportId, action, pluginId, targetType, targetId }` | +| `moderation.report.resolved` | `{ reportId, resolution }` | +| `moderation.report.reopened` | `{ reportId }` | +| `moderation.report.note_updated` | `{ reportId, noteId }` | +| `moderation.report.note_deleted` | `{ reportId, noteId }` | + +Adding a note emits no event. Hiding through a report also emits the content type's own `hidden` or `unhidden` event. See [Built-in events](/docs/dev/events/built-in-events#moderation-events). + +## Snapshot format + +Stored in `core_moderation_reports.snapshot` as JSON, `version: 1`: + +| Field | Description | +| ----------- | ---------------------------------------------------------------------- | +| `capturedAt` | When the server captured it | +| `target` | `{ pluginId, type, id }` | +| `title` | The display title | +| `fields` | `{ name, label, kind, value }` with `kind` `text`, `richText`, `file` or `value` | +| `locale` | `{ requested, served, fallback }` | +| `source` | `{ version, updatedAt, publishedAt }` of the captured state | +| `authors` | `{ id, name }` of the record's authors | +| `scope` | The scope resource at capture time, as evidence | +| `url` | The public address at capture time | +| `truncated` | `true` when text was cut to fit | + +The snapshot is read in one read-only transaction, so its fields and `source` describe the same state. The ModCP renders every snapshot with a generic viewer that needs no plugin code, and rich text goes through the same element allowlist as the public site. + +## Limits + +| Limit | Value | +| -------------------------------- | ---------- | +| Report details | 2000 characters, at least 10 for `other` | +| Plain-text notes, reopen reasons | 3 to 2000 characters | +| Resolution explanations | Optional, up to 2000 characters | +| Rich-text notes | 64 KB, not empty | +| One text value in a snapshot | 20,000 characters | +| Whole snapshot | 256 KB; rich text falls back to plain text, then fields are dropped | +| Reports per user | 20 per hour | +| Open reports per reporter and target | 1 | +| Queue page size | 25 by default, up to 100 | + +File fields keep `{ id, name, mimeType, size, url }` only. The file's bytes are never copied, so a file deleted later cannot be opened from the report. + +## Database + +The migration `20261011193321_add_moderation_reports` adds `core_moderation_reports` and `core_moderation_report_events`. Neither references the reported content, so deleting content keeps its reports. Reporter, assignee and resolver are `ON DELETE SET NULL`, so deleting an account keeps the evidence. The unique `openKey` column enforces one open report per reporter and target, even under concurrent submissions. diff --git a/apps/web/content/docs/dev/moderation/scoped-permissions.mdx b/apps/web/content/docs/dev/moderation/scoped-permissions.mdx new file mode 100644 index 0000000000..a046dfdcc1 --- /dev/null +++ b/apps/web/content/docs/dev/moderation/scoped-permissions.mdx @@ -0,0 +1,155 @@ +--- +title: Scoped moderator permissions +description: Limit VitNode moderator permissions to selected resources, such as some forum sections or categories, with defineModerationScope and a content model's moderationScope. +icon: ListChecks +--- + +A scoped moderator permission applies to every resource of a **scope family**, or only to the resources an administrator picks. "View reports in every section" and "hide topics only in section B" are two grants of scoped permissions. This guide uses the example plugin, whose article reports are scoped by category. + +## Before you begin + +Scopes build on [staff permissions](/docs/dev/working-with-users/staff-permissions) and on reports. If your content type does not report yet, start with [Enable reports](/docs/dev/moderation/enable-reports). + + + + + +### Register a scope family + +A scope family tells the AdminCP which resources exist. Create it on the server with `defineModerationScope`: + +```ts title="plugins/example/src/api/lib/moderation.ts" +import { defineModerationScope } from "@vitnode/core/api/moderation"; +import { and, asc, gt, ilike, inArray } from "drizzle-orm"; + +import { example_categories } from "@/database/categories"; + +export const exampleCategoryScope = defineModerationScope({ + id: "example.category", + label: "@vitnode/example.moderation.category.label", + description: "@vitnode/example.moderation.category.desc", + list: async (c, { cursor, limit, search }) => { + const rows = await c + .get("db") + .select({ id: example_categories.id, name: example_categories.name }) + .from(example_categories) + .where( + and( + cursor ? gt(example_categories.id, Number(cursor)) : undefined, + search ? ilike(example_categories.name, `%${search}%`) : undefined, + ), + ) + .orderBy(asc(example_categories.id)) + .limit(limit + 1); + const page = rows.slice(0, limit); + + return { + items: page.map(row => ({ id: String(row.id), label: row.name })), + nextCursor: rows.length > limit ? String(page.at(-1)?.id) : null, + }; + }, + find: async (c, ids) => { + const numeric = ids.map(Number).filter(id => Number.isSafeInteger(id)); + if (numeric.length === 0) return []; + + const rows = await c + .get("db") + .select({ id: example_categories.id, name: example_categories.name }) + .from(example_categories) + .where(inArray(example_categories.id, numeric)); + + return rows.map(row => ({ id: String(row.id), label: row.name })); + }, +}); +``` + +`list` feeds the picker one page at a time. `find` labels a saved selection and lets the API refuse an id that does not exist. Return `context`, such as `["Games"]`, on each resource to show where a nested resource sits. Both callbacks read the database, so the family never reaches the browser. + + + + + +### Add it to the plugin + +Pass the family to `buildApiPlugin`, and translate its label: + +```ts title="plugins/example/src/config.api.ts" +buildApiPlugin({ + pluginId: CONFIG_PLUGIN.pluginId, + moderation: { scopes: [exampleCategoryScope] }, // [!code ++] + modules: [/* ... */], +}); +``` + +```json title="plugins/example/src/locales/en.json" +{ + "@vitnode/example": { + "moderation": { + "category": { + "label": "Categories", + "desc": "Limit this permission to articles in some categories." + } + } + } +} +``` + +Family ids are global. Two plugins registering the same id, or a permission naming a family nobody registers, stops the API at boot with a `ModerationRegistryError`. + + + + + +### Tell the content model where a record sits + +On the server-side model, name the base-table column that holds the resource id: + +```ts title="plugins/example/src/database/articles.ts" +export const articleContent = createContentModel(articleContentType, { + references: { category: () => example_categories.id }, + moderationScope: { column: "category", family: "example.category" }, // [!code ++] +}); +``` + +The report permissions of `example.article` are now scoped by `example.category`. The server reads the column itself, so a client can never claim a record is in another category. The definition in `content/article.ts` stays browser-safe, without callbacks. + + + + + +## Check the result + +Rebuild with `pnpm build:plugins` and restart `pnpm dev`. Open **AdminCP → Staff → Moderators**, edit a moderator and select the example plugin. **View reports** and **Claim, assign, annotate and resolve reports** now show a scope under the checkbox, and **Change** opens the category picker. See [Configure moderators](/docs/dev/moderation/configure-moderators). + +## How grants combine + +A user's effective moderator permissions come from their own staff entry and the entries of every role they hold: + +- **Grants of one permission merge.** "View reports in A" from a role and "view reports in B" from the user give "view reports in A and B". "All resources" wins over a selection. +- **Permissions never combine across each other.** A role with "hide in A" and a user entry with "view in B" never allow hiding in B. Each permission keeps its own scope. +- **Dependencies are checked per resource.** Managing reports depends on viewing reports, so "manage in A" needs "view in A". Saving "manage in B" with only "view in A" is refused, and if broken data ever gets through, the effective scope of "manage" is cut down to where "view" is held. +- **Root roles and unrestricted entries** still allow everything, in every scope. + +`isModerator` in the session means "has a moderator entry". It grants no permission by itself, and the ModCP shows only what the grants allow. + +## Compatibility with unscoped grants + +Unscoped permissions work as before. A grant without a `scope` allows the permission everywhere. + +When a plugin later makes an existing permission scoped, grants saved before that change have no scope. They keep meaning **all resources**, and the editor shows them as **All resources**. A new save always needs an explicit scope. The API answers `400` with `missing_scope` instead of guessing that a missing scope means "everything". + +## Check a scope in your own code + +Pass the resource when you check a scoped permission: + +```ts +const allowed = await checkStaffPermission(c, { + type: "moderator", + plugin: "@vitnode/example", + module: "article", + permission: "can_view_reports", + resource: { family: "example.category", id: "2" }, +}); +``` + +A check without `resource` only passes for an unscoped or an **All resources** grant, so older code can never be widened by a selection. When a record moves to another resource, call `syncModerationReportScope(c, { pluginId, targetType, targetId })` from `@vitnode/core/api/moderation` after the move, so its reports follow it. The ModCP also re-reads the scope every time it opens a report. diff --git a/apps/web/content/docs/dev/working-with-users/staff-permissions.mdx b/apps/web/content/docs/dev/working-with-users/staff-permissions.mdx index 3d66e3ebd8..faa931901b 100644 --- a/apps/web/content/docs/dev/working-with-users/staff-permissions.mdx +++ b/apps/web/content/docs/dev/working-with-users/staff-permissions.mdx @@ -159,6 +159,27 @@ permissionStaff: { The AdminCP permission editor automatically disables child toggles until prerequisites are checked. +## Moderator permissions with scopes + +A moderator permission can be limited to some resources of a scope family, such as some forum sections. Declare the family with `scope` on the entry: + +```ts title="config.api.ts" +permissionStaff: { + moderator: { + topics: [ + { permission: "can_view_reports", scope: "forum.section" }, + { + permission: "can_manage_reports", + dependsOn: ["can_view_reports"], + scope: "forum.section", + }, + ], + }, +}, +``` + +Each grant then carries a scope, and dependencies are checked per resource: managing in a section needs viewing in that section. The scope families and the AdminCP picker are covered in [Scoped moderator permissions](/docs/dev/moderation/scoped-permissions). + ## Learn More diff --git a/apps/web/content/docs/ui/user.mdx b/apps/web/content/docs/ui/user.mdx index 20c95fadf4..937606c34e 100644 --- a/apps/web/content/docs/ui/user.mdx +++ b/apps/web/content/docs/ui/user.mdx @@ -1,6 +1,6 @@ --- title: User -description: Pick a person by name, with their avatar, in an Auto Form field that stores the user id. +description: Pick a person in an Auto Form field that stores the user id, or show a member's name with their role color and a link to their profile. icon: CircleUser --- @@ -63,6 +63,50 @@ that sets the value to `null`, so pair it with `z.number().nullable()`. `selected` can be partial: just `id` and `name`. Without `avatarColor`, the field draws a neutral placeholder avatar instead of guessing a color. +## Role colors + + + +Add a `role` to each option and the picker draws the name the way the rest of +the site does: in the role's color, with its prefix icon or emoji in front. +Options without a `role` keep a plain name, so you can add it gradually. + +```tsx +const moderators: UserOption[] = [ + { + avatarColor: '2563eb', + id: 1, + name: 'Ada Lovelace', + nameCode: 'ada', + role: { color: '#dc2626', prefix: 'icon:shield' }, + }, +] +``` + +`prefix` takes the same `icon:` or `emoji:` value as a +role's prefix in the AdminCP. In the AdminCP, `searchAdminUsersInBrowser` +already returns each person's primary role, so its pickers show colored names +without extra work. + +## Show a member's name + + + +`UserFormat` prints a name the way the rest of the site does. `format` adds the +role color and prefix, and `link` turns the name into a link to the member's +profile at `/users/`. + +```tsx +import { UserFormat } from '@vitnode/core/components/user-format' + + +``` + +`user` is `{ name, nameCode?, role: { color, prefix? } }`. `link` needs a +`nameCode`; without one, the name renders as plain text. Leave `link` off when +the name sits inside another link or a button, such as a table row that +already opens something. + ## Custom search `search` can call any endpoint: a plugin's own members, fixtures in a test. @@ -90,8 +134,9 @@ types. Results are never cached, so deleted users don't linger. | `clearable` | `boolean` | `false` | Adds a button that sets `null` | | `disabled` | `boolean` | `false` | Blocks opening the picker and clearing | -`UserOption` is `{ id, name, nameCode, avatarColor }`. `PartialUserOption` -makes `nameCode` and `avatarColor` optional. +`UserOption` is `{ id, name, nameCode, avatarColor, role? }`, where `role` is +`{ color: string | null, prefix?: string | null }`. `PartialUserOption` makes +`nameCode` and `avatarColor` optional. ## In the Content Engine diff --git a/apps/web/src/docs/examples/user-format.tsx b/apps/web/src/docs/examples/user-format.tsx new file mode 100644 index 0000000000..e8baad3c29 --- /dev/null +++ b/apps/web/src/docs/examples/user-format.tsx @@ -0,0 +1,30 @@ +import { UserFormat } from '@vitnode/core/components/user-format' + +const MEMBERS = [ + { + name: 'Ada Lovelace', + nameCode: 'ada', + role: { color: '#dc2626', prefix: 'icon:crown' }, + }, + { + name: 'Grace Hopper', + nameCode: 'grace', + role: { color: '#16a34a', prefix: 'icon:shield' }, + }, + { name: 'Alan Turing', nameCode: 'alan', role: { color: null } }, +] + +export default function UserFormatExample() { + return ( +
    + {MEMBERS.map((member) => ( +
  • + + + /users/{member.nameCode} + +
  • + ))} +
+ ) +} diff --git a/apps/web/src/docs/examples/user-roles.tsx b/apps/web/src/docs/examples/user-roles.tsx new file mode 100644 index 0000000000..08e2297c08 --- /dev/null +++ b/apps/web/src/docs/examples/user-roles.tsx @@ -0,0 +1,65 @@ +import { AutoForm } from '@vitnode/core/components/form/auto-form' +import { + AutoFormUser, + type UserOption, +} from '@vitnode/core/components/form/fields/input-users' +import { z } from 'zod' + +const formSchema = z.object({ + assigneeId: z.number().nullable().default(1), +}) + +const MODERATORS: UserOption[] = [ + { + avatarColor: '2563eb', + id: 1, + name: 'Ada Lovelace', + nameCode: 'ada', + role: { color: '#dc2626', prefix: 'icon:shield' }, + }, + { + avatarColor: 'db2777', + id: 2, + name: 'Grace Hopper', + nameCode: 'grace', + role: { color: '#16a34a' }, + }, + { + avatarColor: '0891b2', + id: 3, + name: 'Alan Turing', + nameCode: 'alan', + role: { color: null }, + }, +] + +export default function UserRolesExample() { + return ( + ( + + Promise.resolve( + MODERATORS.filter((person) => + person.name.toLowerCase().includes(value.toLowerCase()), + ), + ) + } + selected={MODERATORS[0]} + /> + ), + }, + ]} + formSchema={formSchema} + /> + ) +} diff --git a/packages/vitnode/src/api/lib/check-staff-permission.test.ts b/packages/vitnode/src/api/lib/check-staff-permission.test.ts index 89f4b04901..8121c7692f 100644 --- a/packages/vitnode/src/api/lib/check-staff-permission.test.ts +++ b/packages/vitnode/src/api/lib/check-staff-permission.test.ts @@ -10,11 +10,17 @@ import { createTestCache } from "@/tests/cache"; import { createMemoryDb } from "@/tests/memory-db"; import { grantStaffPermissions } from "@/tests/staff-permissions"; +import type { + PermissionsStaffArgs, + PermissionStaffCatalogEntry, +} from "./permission-staff"; + import { getStaffFlags, isStaff, resolveStaffPermissions, } from "./check-staff-permission"; +import { normalizePermissionStaffModules } from "./permission-staff"; import { invalidateAllStaffPermissions, invalidateStaffEntry, @@ -33,7 +39,7 @@ const DELETE_POSTS = { }; const staffEntry = (entry: { - permissions?: (typeof DELETE_POSTS)[]; + permissions?: PermissionsStaffArgs[]; roleId?: number; unrestricted?: boolean; userId?: number; @@ -48,11 +54,13 @@ const staffEntry = (entry: { const contextWith = ({ admin = [], cache = createTestCache(), + catalog, moderator = [], secondaryRoleIds = [], }: { admin?: ReturnType[]; cache?: ReturnType; + catalog?: PermissionStaffCatalogEntry[]; moderator?: ReturnType[]; secondaryRoleIds?: number[]; }) => { @@ -73,7 +81,11 @@ const contextWith = ({ [core_admin_permissions, admin], [core_moderators_permissions, moderator], ]); - const values: Record = { cache, db }; + const values: Record = { + cache, + core: catalog ? { permissionStaff: catalog } : undefined, + db, + }; return Object.assign( { get: (key: string) => values[key] } as unknown as Context, @@ -251,3 +263,106 @@ describe("getStaffFlags", () => { expect((await getStaffFlags(c, USER)).isAdmin).toBe(false); }); }); + +describe("resolveStaffPermissions with scoped grants", () => { + const FAMILY = "forum.section"; + const reports = ( + permission: string, + ids: string[], + ): PermissionsStaffArgs => ({ + module: "topics", + permission, + plugin: "@vitnode/forum", + scope: { family: FAMILY, ids, mode: "selected" }, + }); + const CATALOG: PermissionStaffCatalogEntry[] = [ + { + admin: {}, + moderator: normalizePermissionStaffModules({ + topics: [ + { permission: "can_view_reports", scope: FAMILY }, + { + dependsOn: ["can_view_reports"], + permission: "can_manage_reports", + scope: FAMILY, + }, + ], + }), + pluginId: "@vitnode/forum", + }, + ]; + + it("keeps the distinct scopes a user entry and a role entry grant", async () => { + const c = contextWith({ + moderator: [ + staffEntry({ + permissions: [reports("can_view_reports", ["a"])], + userId: USER.id, + }), + staffEntry({ + permissions: [reports("can_view_reports", ["b"])], + roleId: STAFF_ROLE, + }), + ], + secondaryRoleIds: [STAFF_ROLE], + }); + + expect( + await resolveStaffPermissions(c, { type: "moderator", user: USER }), + ).toEqual({ + permissions: [reports("can_view_reports", ["a", "b"])], + root: false, + staff: true, + }); + }); + + it("narrows a grant to where its dependency is held, across entries", async () => { + const c = contextWith({ + catalog: CATALOG, + moderator: [ + staffEntry({ + permissions: [reports("can_view_reports", ["a"])], + userId: USER.id, + }), + staffEntry({ + permissions: [reports("can_manage_reports", ["a", "b"])], + roleId: STAFF_ROLE, + }), + ], + secondaryRoleIds: [STAFF_ROLE], + }); + + const { permissions } = await resolveStaffPermissions(c, { + type: "moderator", + user: USER, + }); + + expect(permissions).toEqual([ + reports("can_view_reports", ["a"]), + reports("can_manage_reports", ["a"]), + ]); + }); + + it("reflects a changed scope once the entry is invalidated", async () => { + const c = contextWith({ + moderator: [ + staffEntry({ + permissions: [reports("can_view_reports", ["a"])], + userId: USER.id, + }), + ], + }); + const resolve = async () => + (await resolveStaffPermissions(c, { type: "moderator", user: USER })) + .permissions; + expect(await resolve()).toEqual([reports("can_view_reports", ["a"])]); + + await c.db + .update(core_moderators_permissions) + .set({ permissions: [reports("can_view_reports", ["b"])] }); + expect(await resolve()).toEqual([reports("can_view_reports", ["a"])]); + + await invalidateStaffEntry(c, { userId: USER.id }); + expect(await resolve()).toEqual([reports("can_view_reports", ["b"])]); + }); +}); diff --git a/packages/vitnode/src/api/lib/check-staff-permission.ts b/packages/vitnode/src/api/lib/check-staff-permission.ts index be5a2095fa..feec43f155 100644 --- a/packages/vitnode/src/api/lib/check-staff-permission.ts +++ b/packages/vitnode/src/api/lib/check-staff-permission.ts @@ -13,8 +13,13 @@ import type { PermissionStaffType, ResolvedStaffPermissionSet, } from "./permission-staff"; +import type { StaffPermissionCheck } from "./staff-permission"; -import { hasStaffPermission, staffPermissionKey } from "./staff-permission"; +import { + applyScopedStaffDependencies, + hasStaffPermission, + mergeStaffPermissionGrants, +} from "./staff-permission"; import { readStaffPermissions, readStaffPermissionsOfUser, @@ -83,22 +88,20 @@ const findStaffEntries = async ( }; const toStaffPermissionSet = ( + c: Context, entries: Awaited>, ): ResolvedStaffPermissionSet => { if (entries.some(entry => entry.unrestricted)) { return rootStaffPermissions(); } - const seen = new Set(); - const permissions: PermissionsStaffArgs[] = []; - for (const entry of entries) { - for (const permission of entry.permissions ?? []) { - const key = staffPermissionKey(permission); - if (seen.has(key)) continue; - seen.add(key); - permissions.push(permission); - } - } + const merged: PermissionsStaffArgs[] = mergeStaffPermissionGrants( + entries.flatMap(entry => entry.permissions ?? []), + ); + const catalog = c.get("core")?.permissionStaff; + const permissions = catalog + ? applyScopedStaffDependencies(merged, catalog) + : merged; return { root: false, permissions, staff: entries.length > 0 }; }; @@ -112,6 +115,7 @@ const loadStaffPermissions = async ( if (await hasRootRole(c, roleIds)) return rootStaffPermissions(); return toStaffPermissionSet( + c, await findStaffEntries(c, { roleIds, type, userId: user.id }), ); }; @@ -197,7 +201,7 @@ const loadMissingStaffPermissions = async ( return entriesByType.map(([type, entries]) => [ type, - root ? rootStaffPermissions() : toStaffPermissionSet(entries), + root ? rootStaffPermissions() : toStaffPermissionSet(c, entries), ]); }; @@ -229,7 +233,7 @@ export const getStaffFlags = async ( export const checkStaffPermissionOfUser = async ( c: Context, user: StaffUser, - { type, ...args }: PermissionsStaffArgs & { type: PermissionStaffType }, + { type, ...args }: StaffPermissionCheck & { type: PermissionStaffType }, ): Promise => { const set = await resolveStaffPermissions(c, { type, user }); @@ -238,7 +242,7 @@ export const checkStaffPermissionOfUser = async ( export const checkStaffPermission = async ( c: Context, - args: PermissionsStaffArgs & { type: PermissionStaffType }, + args: StaffPermissionCheck & { type: PermissionStaffType }, ): Promise => { const user = args.type === "admin" ? c.get("admin")?.user : c.get("user"); if (!user) return false; @@ -248,7 +252,7 @@ export const checkStaffPermission = async ( export const assertStaffPermission = async ( c: Context, - args: PermissionsStaffArgs & { type: PermissionStaffType }, + args: StaffPermissionCheck & { type: PermissionStaffType }, ): Promise => { const allowed = await checkStaffPermission(c, args); if (!allowed) { diff --git a/packages/vitnode/src/api/lib/moderation/access.ts b/packages/vitnode/src/api/lib/moderation/access.ts new file mode 100644 index 0000000000..e7d08cf392 --- /dev/null +++ b/packages/vitnode/src/api/lib/moderation/access.ts @@ -0,0 +1,258 @@ +import type { SQL } from "drizzle-orm"; +import type { Context } from "hono"; + +import { and, eq, inArray, or } from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; + +import { + core_moderation_report_events, + core_moderation_reports, +} from "@/database/moderation"; +import { core_users } from "@/database/users"; +import { MODERATION_PERMISSIONS } from "@/lib/moderation/const"; + +import type { ResolvedStaffPermissionSet } from "../permission-staff"; +import type { RegisteredReportTarget } from "./targets"; + +import { resolveStaffPermissions } from "../check-staff-permission"; +import { + hasStaffPermission, + staffPermissionResources, +} from "../staff-permission"; +import { findReportTarget } from "./registry"; + +export interface ModeratorContext { + set: ResolvedStaffPermissionSet; + user: { id: number; roleId: number }; +} + +export const requireModerator = async ( + c: Context, +): Promise => { + const user = c.get("user"); + if (!user) throw new HTTPException(401, { message: "Sign in first." }); + + const set = await resolveStaffPermissions(c, { type: "moderator", user }); + if (!set.staff) throw new HTTPException(403, { message: "Forbidden" }); + + return { set, user }; +}; + +export interface ReportScopeRef { + pluginId: string; + scopeId: null | string; + targetType: string; +} + +export const canOnReport = ( + c: Context, + set: ResolvedStaffPermissionSet, + report: ReportScopeRef, + permission: string, +): boolean => { + if (set.root) return true; + + const target = findReportTarget(c.get("core").moderation, { + pluginId: report.pluginId, + type: report.targetType, + }); + if (!target) return false; + + return hasStaffPermission(set, { + module: target.definition.permissionModule, + permission, + plugin: target.pluginId, + ...(target.definition.scope + ? { + resource: { + family: target.definition.scope, + id: report.scopeId ?? "", + }, + } + : {}), + }); +}; + +export const viewableReportsCondition = ( + c: Context, + set: ResolvedStaffPermissionSet, +): SQL | undefined => { + if (set.root) return undefined; + + const table = core_moderation_reports; + const conditions: (SQL | undefined)[] = []; + for (const target of c.get("core").moderation?.targets ?? []) { + const family = target.definition.scope; + const resources = staffPermissionResources( + set, + { + module: target.definition.permissionModule, + permission: MODERATION_PERMISSIONS.viewReports, + plugin: target.pluginId, + }, + family ?? "", + ); + if (resources === null) continue; + + const ofTarget = and( + eq(table.pluginId, target.pluginId), + eq(table.targetType, target.definition.type), + ); + conditions.push( + resources === "all" || !family + ? ofTarget + : and( + ofTarget, + eq(table.scopeFamily, family), + inArray(table.scopeId, resources), + ), + ); + } + + return ( + (conditions.length > 0 ? or(...conditions) : undefined) ?? eq(table.id, 0) + ); +}; + +export const syncReportTargetScope = async ( + c: Context, + { target, targetId }: { target: RegisteredReportTarget; targetId: string }, +): Promise => { + const { definition, pluginId } = target; + if (!definition.scope || !definition.resolveScope) return null; + + const current = await definition.resolveScope(c, targetId); + if (current === undefined) return undefined; + + const table = core_moderation_reports; + const ofTarget = and( + eq(table.pluginId, pluginId), + eq(table.targetType, definition.type), + eq(table.targetId, targetId), + ); + const stale = await c + .get("db") + .select({ + assigneeId: table.assigneeId, + id: table.id, + scopeId: table.scopeId, + status: table.status, + version: table.version, + }) + .from(table) + .where(ofTarget); + + const moved = stale.filter(report => report.scopeId !== current); + if (moved.length === 0) return current; + + for (const report of moved) { + await c + .get("db") + .update(table) + .set({ + scopeFamily: definition.scope, + scopeId: current, + version: report.version + 1, + }) + .where(and(eq(table.id, report.id), eq(table.version, report.version))); + report.version += 1; + } + + await revalidateAssignees(c, { + reports: moved.filter( + report => report.assigneeId !== null && report.status !== "done", + ), + scopeId: current, + target, + }); + + return current; +}; + +const revalidateAssignees = async ( + c: Context, + { + reports, + scopeId, + target, + }: { + reports: { assigneeId: null | number; id: number; version: number }[]; + scopeId: string; + target: RegisteredReportTarget; + }, +): Promise => { + const assigneeIds = [ + ...new Set( + reports + .map(report => report.assigneeId) + .filter((id): id is number => id !== null), + ), + ]; + if (assigneeIds.length === 0) return; + + const users = await c + .get("db") + .select({ id: core_users.id, roleId: core_users.roleId }) + .from(core_users) + .where(inArray(core_users.id, assigneeIds)); + + for (const assigneeId of assigneeIds) { + const user = users.find(row => row.id === assigneeId); + const set = user + ? await resolveStaffPermissions(c, { type: "moderator", user }) + : null; + const keeps = + set !== null && + canOnReport( + c, + set, + { + pluginId: target.pluginId, + scopeId, + targetType: target.definition.type, + }, + MODERATION_PERMISSIONS.manageReports, + ); + if (keeps) continue; + + const lost = reports.filter(report => report.assigneeId === assigneeId); + await c.get("db").transaction(async tx => { + for (const report of lost) { + const [updated] = await tx + .update(core_moderation_reports) + .set({ assigneeId: null, status: "new", version: report.version + 1 }) + .where( + and( + eq(core_moderation_reports.id, report.id), + eq(core_moderation_reports.version, report.version), + ), + ) + .returning({ id: core_moderation_reports.id }); + if (!updated) continue; + await tx.insert(core_moderation_report_events).values({ + actorId: null, + data: { previousAssigneeId: assigneeId, reason: "lost_access" }, + reportId: report.id, + type: "unassigned", + }); + } + }); + } +}; + +export const syncModerationReportScope = async ( + c: Context, + { + pluginId, + targetId, + targetType, + }: { pluginId: string; targetId: string; targetType: string }, +): Promise => { + const target = findReportTarget(c.get("core").moderation, { + pluginId, + type: targetType, + }); + if (!target) return; + + await syncReportTargetScope(c, { target, targetId }); +}; diff --git a/packages/vitnode/src/api/lib/moderation/registry.test.ts b/packages/vitnode/src/api/lib/moderation/registry.test.ts new file mode 100644 index 0000000000..35d45fcb8d --- /dev/null +++ b/packages/vitnode/src/api/lib/moderation/registry.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it } from "vitest"; + +import type { PermissionStaffCatalogEntry } from "../permission-staff"; +import type { ModerationReportTargetDefinition } from "./targets"; + +import { normalizePermissionStaffModules } from "../permission-staff"; +import { + validateModerationRegistry, + withModerationPermissions, +} from "./registry"; +import { defineModerationScope, ModerationRegistryError } from "./scopes"; +import { defineReportTarget } from "./targets"; + +const FORUM = "@vitnode/forum"; +const FAMILY = "forum.section"; + +const action = { + available: () => true, + permission: "can_hide", + run: async () => Promise.resolve(null), +}; + +const target = ( + overrides: Partial = {}, +): ModerationReportTargetDefinition => + defineReportTarget({ + capture: async () => Promise.resolve(null), + current: async () => Promise.resolve(null), + label: "Topic", + parseId: value => value, + permissionModule: "topics", + resolveScope: async () => Promise.resolve(""), + scope: FAMILY, + type: "forum.topic", + ...overrides, + }); + +const scope = defineModerationScope({ + find: async () => Promise.resolve([]), + id: FAMILY, + label: "Section", + list: async () => Promise.resolve({ items: [], nextCursor: null }), +}); + +describe("withModerationPermissions", () => { + it("generates view, manage and note permissions, scoped like the target, without hiding", () => { + expect(withModerationPermissions(undefined, [target()])).toEqual({ + moderator: { + topics: [ + { permission: "can_view_reports", scope: FAMILY }, + { + dependsOn: ["can_view_reports"], + permission: "can_manage_reports", + scope: FAMILY, + }, + { + dependsOn: ["can_manage_reports"], + permission: "can_edit_notes", + scope: FAMILY, + }, + { + dependsOn: ["can_manage_reports"], + permission: "can_delete_notes", + scope: FAMILY, + }, + ], + }, + }); + }); + + it.each(["hide", "unhide"] as const)( + "adds view hidden and hide when the target can %s", + name => { + const generated = withModerationPermissions(undefined, [ + target({ actions: { [name]: action } }), + ]); + + expect(generated?.moderator?.topics).toEqual( + expect.arrayContaining([ + { permission: "can_view_hidden", scope: FAMILY }, + { + dependsOn: ["can_view_hidden"], + permission: "can_hide", + scope: FAMILY, + }, + ]), + ); + }, + ); + + it("leaves an unscoped target's permissions unscoped", () => { + const generated = withModerationPermissions(undefined, [ + target({ resolveScope: undefined, scope: undefined }), + ]); + + expect(generated?.moderator?.topics).toEqual([ + { permission: "can_view_reports" }, + { dependsOn: ["can_view_reports"], permission: "can_manage_reports" }, + { dependsOn: ["can_manage_reports"], permission: "can_edit_notes" }, + { dependsOn: ["can_manage_reports"], permission: "can_delete_notes" }, + ]); + }); + + it("keeps a module the plugin declared itself", () => { + const declared = { moderator: { topics: ["can_view_reports"] } }; + + expect(withModerationPermissions(declared, [target()])).toEqual(declared); + }); +}); + +describe("validateModerationRegistry", () => { + const catalog = ( + entry: Partial = {}, + ): PermissionStaffCatalogEntry[] => [ + { admin: {}, moderator: {}, pluginId: FORUM, ...entry }, + ]; + + it("accepts scopes, targets and scoped moderator permissions that line up", () => { + const registry = validateModerationRegistry({ + catalog: catalog({ + moderator: normalizePermissionStaffModules({ + topics: [{ permission: "can_view_reports", scope: FAMILY }], + }), + }), + scopes: [{ definition: scope, pluginId: FORUM }], + targets: [{ definition: target(), pluginId: FORUM }], + }); + + expect(registry.targets.map(entry => entry.definition.type)).toEqual([ + "forum.topic", + ]); + }); + + it.each<[string, Parameters[0]]>([ + [ + "a scoped admin permission", + { + catalog: catalog({ + admin: normalizePermissionStaffModules({ + topics: [{ permission: "can_edit", scope: FAMILY }], + }), + }), + scopes: [{ definition: scope, pluginId: FORUM }], + targets: [], + }, + ], + [ + "a moderator permission scoped by an unknown family", + { + catalog: catalog({ + moderator: normalizePermissionStaffModules({ + topics: [{ permission: "can_view_reports", scope: "forum.nope" }], + }), + }), + scopes: [{ definition: scope, pluginId: FORUM }], + targets: [], + }, + ], + [ + "a target scoped by an unknown family", + { + catalog: catalog(), + scopes: [], + targets: [{ definition: target(), pluginId: FORUM }], + }, + ], + [ + "a family registered twice", + { + catalog: catalog(), + scopes: [ + { definition: scope, pluginId: FORUM }, + { definition: scope, pluginId: "@vitnode/other" }, + ], + targets: [], + }, + ], + [ + "a target type registered twice", + { + catalog: catalog(), + scopes: [{ definition: scope, pluginId: FORUM }], + targets: [ + { definition: target(), pluginId: FORUM }, + { definition: target(), pluginId: "@vitnode/other" }, + ], + }, + ], + ])("rejects %s", (_label, input) => { + expect(() => validateModerationRegistry(input)).toThrow( + ModerationRegistryError, + ); + }); +}); + +describe("defineReportTarget", () => { + it("requires resolveScope on a scoped target", () => { + expect(() => target({ resolveScope: undefined })).toThrow( + ModerationRegistryError, + ); + }); +}); diff --git a/packages/vitnode/src/api/lib/moderation/registry.ts b/packages/vitnode/src/api/lib/moderation/registry.ts new file mode 100644 index 0000000000..5ae22f4060 --- /dev/null +++ b/packages/vitnode/src/api/lib/moderation/registry.ts @@ -0,0 +1,156 @@ +import { MODERATION_PERMISSIONS } from "@/lib/moderation/const"; + +import type { + PermissionStaffCatalogEntry, + PermissionStaffConfig, + PermissionStaffEntryInput, +} from "../permission-staff"; +import type { + ModerationScopeDefinition, + RegisteredModerationScope, +} from "./scopes"; +import type { + ModerationReportTargetDefinition, + RegisteredReportTarget, +} from "./targets"; + +import { ModerationRegistryError } from "./scopes"; + +export interface ModerationPluginConfig { + reportTargets?: ModerationReportTargetDefinition[]; + scopes?: ModerationScopeDefinition[]; +} + +export interface ModerationRegistry { + scopes: RegisteredModerationScope[]; + targets: RegisteredReportTarget[]; +} + +export const moderationPermissionEntries = ( + target: Pick, +): PermissionStaffEntryInput[] => { + const scope = target.scope ? { scope: target.scope } : {}; + const hideable = Boolean(target.actions?.hide ?? target.actions?.unhide); + + return [ + { permission: MODERATION_PERMISSIONS.viewReports, ...scope }, + { + dependsOn: [MODERATION_PERMISSIONS.viewReports], + permission: MODERATION_PERMISSIONS.manageReports, + ...scope, + }, + { + dependsOn: [MODERATION_PERMISSIONS.manageReports], + permission: MODERATION_PERMISSIONS.editNotes, + ...scope, + }, + { + dependsOn: [MODERATION_PERMISSIONS.manageReports], + permission: MODERATION_PERMISSIONS.deleteNotes, + ...scope, + }, + ...(hideable + ? [ + { permission: MODERATION_PERMISSIONS.viewHidden, ...scope }, + { + dependsOn: [MODERATION_PERMISSIONS.viewHidden], + permission: MODERATION_PERMISSIONS.hide, + ...scope, + }, + ] + : []), + ]; +}; + +export const withModerationPermissions = ( + permissionStaff: PermissionStaffConfig | undefined, + targets: readonly ModerationReportTargetDefinition[], +): PermissionStaffConfig | undefined => { + if (targets.length === 0) return permissionStaff; + + const moderator = { ...permissionStaff?.moderator }; + for (const target of targets) { + if (moderator[target.permissionModule]) continue; + moderator[target.permissionModule] = moderationPermissionEntries(target); + } + + return { ...permissionStaff, moderator }; +}; + +export const validateModerationRegistry = ({ + catalog, + scopes, + targets, +}: { + catalog: readonly PermissionStaffCatalogEntry[]; + scopes: readonly RegisteredModerationScope[]; + targets: readonly RegisteredReportTarget[]; +}): ModerationRegistry => { + const families = new Map(); + for (const entry of scopes) { + const existing = families.get(entry.definition.id); + if (existing) { + throw new ModerationRegistryError( + `Scope family "${entry.definition.id}" is registered by both ${existing.pluginId} and ${entry.pluginId}.`, + ); + } + families.set(entry.definition.id, entry); + } + + for (const plugin of catalog) { + for (const [module, entries] of Object.entries(plugin.admin)) { + const scoped = entries.find(entry => entry.scope); + if (scoped) { + throw new ModerationRegistryError( + `${plugin.pluginId}:${module}:${scoped.permission} is an admin permission with a scope. Only moderator permissions can be scoped.`, + ); + } + } + for (const [module, entries] of Object.entries(plugin.moderator)) { + for (const entry of entries) { + if (entry.scope && !families.has(entry.scope)) { + throw new ModerationRegistryError( + `${plugin.pluginId}:${module}:${entry.permission} is scoped by "${entry.scope}", which no plugin registers with \`moderation.scopes\`.`, + ); + } + } + } + } + + const types = new Map(); + for (const entry of targets) { + const existing = types.get(entry.definition.type); + if (existing) { + throw new ModerationRegistryError( + `Report target "${entry.definition.type}" is registered by both ${existing.pluginId} and ${entry.pluginId}.`, + ); + } + if (entry.definition.scope && !families.has(entry.definition.scope)) { + throw new ModerationRegistryError( + `Report target "${entry.definition.type}" is scoped by "${entry.definition.scope}", which no plugin registers.`, + ); + } + types.set(entry.definition.type, entry); + } + + return { + scopes: [...families.values()], + targets: [...types.values()].sort((a, b) => + a.definition.type.localeCompare(b.definition.type), + ), + }; +}; + +export const findReportTarget = ( + registry: ModerationRegistry | undefined, + { pluginId, type }: { pluginId: string; type: string }, +): RegisteredReportTarget | undefined => + registry?.targets.find( + entry => entry.pluginId === pluginId && entry.definition.type === type, + ); + +export const findModerationScope = ( + registry: ModerationRegistry | undefined, + family: string, +): RegisteredModerationScope | undefined => + registry?.scopes.find(entry => entry.definition.id === family); diff --git a/packages/vitnode/src/api/lib/moderation/scopes.ts b/packages/vitnode/src/api/lib/moderation/scopes.ts new file mode 100644 index 0000000000..89f01d1b77 --- /dev/null +++ b/packages/vitnode/src/api/lib/moderation/scopes.ts @@ -0,0 +1,61 @@ +import type { Context } from "hono"; + +import { + MODERATION_IDENTIFIER_PATTERN, + MODERATION_RESOURCE_ID_PATTERN, +} from "@/lib/moderation/const"; + +export interface ModerationScopeResource { + context?: string[]; + id: string; + label: string; +} + +export interface ModerationScopeListArgs { + cursor: null | string; + limit: number; + search?: string; +} + +export interface ModerationScopeListResult { + items: ModerationScopeResource[]; + nextCursor: null | string; +} + +export interface ModerationScopeDefinition { + description?: string; + find: (c: Context, ids: string[]) => Promise; + id: string; + label: string; + list: ( + c: Context, + args: ModerationScopeListArgs, + ) => Promise; +} + +export interface RegisteredModerationScope { + definition: ModerationScopeDefinition; + pluginId: string; +} + +export class ModerationRegistryError extends Error { + constructor(message: string) { + super(`[moderation] ${message}`); + this.name = "ModerationRegistryError"; + } +} + +export const defineModerationScope = ( + definition: ModerationScopeDefinition, +): ModerationScopeDefinition => { + if (!MODERATION_IDENTIFIER_PATTERN.test(definition.id)) { + throw new ModerationRegistryError( + `Scope family id "${definition.id}" must be lowercase and dot separated, like "forum.section".`, + ); + } + + return definition; +}; + +export const isModerationResourceId = (value: string): boolean => + MODERATION_RESOURCE_ID_PATTERN.test(value); diff --git a/packages/vitnode/src/api/lib/moderation/snapshot.test.ts b/packages/vitnode/src/api/lib/moderation/snapshot.test.ts new file mode 100644 index 0000000000..e6fcd3c4f7 --- /dev/null +++ b/packages/vitnode/src/api/lib/moderation/snapshot.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; + +import { + MODERATION_LIMITS, + MODERATION_SNAPSHOT_VERSION, +} from "@/lib/moderation/const"; + +import type { ModerationCaptureResult } from "./targets"; + +import { sealReportSnapshot } from "./snapshot"; + +const TARGET = { id: "7", pluginId: "@vitnode/forum", type: "forum.topic" }; +const CAPTURED_AT = new Date("2026-01-02T03:04:05.000Z"); + +const capture = ( + overrides: Partial = {}, +): ModerationCaptureResult => ({ + authors: [{ id: 1, name: "Ada" }], + fields: [{ kind: "text", label: null, name: "body", value: "Hello" }], + locale: { fallback: false, requested: "en", served: "en" }, + scope: null, + source: { publishedAt: null, updatedAt: null, version: null }, + title: "A topic", + url: "/topics/7", + ...overrides, +}); + +const seal = (overrides: Partial = {}) => + sealReportSnapshot({ + capture: capture(overrides), + capturedAt: CAPTURED_AT, + target: TARGET, + }); + +describe("sealReportSnapshot", () => { + it("stamps, versions and keeps a small capture whole", () => { + expect(seal()).toEqual({ + ...capture(), + capturedAt: CAPTURED_AT.toISOString(), + target: TARGET, + truncated: false, + version: MODERATION_SNAPSHOT_VERSION, + }); + }); + + it("cuts a long text value and the title, and says so", () => { + const snapshot = seal({ + fields: [ + { + kind: "text", + label: null, + name: "body", + value: "x".repeat(MODERATION_LIMITS.snapshotTextMaxLength + 10), + }, + ], + title: "t".repeat(600), + }); + + expect(snapshot.truncated).toBe(true); + expect(snapshot.title).toHaveLength(500); + expect(snapshot.fields[0]?.value).toHaveLength( + MODERATION_LIMITS.snapshotTextMaxLength, + ); + }); + + it("drops fields from the end until the document fits", () => { + const big = (name: string) => ({ + kind: "text" as const, + label: null, + name, + value: "y".repeat(MODERATION_LIMITS.snapshotTextMaxLength), + }); + const count = + Math.ceil( + MODERATION_LIMITS.snapshotMaxBytes / + MODERATION_LIMITS.snapshotTextMaxLength, + ) + 2; + + const snapshot = seal({ + fields: Array.from({ length: count }, (_, index) => big(`f${index}`)), + }); + + expect(snapshot.truncated).toBe(true); + expect(snapshot.fields.length).toBeLessThan(count); + expect(snapshot.fields[0]?.name).toBe("f0"); + expect( + Buffer.byteLength(JSON.stringify(snapshot), "utf8"), + ).toBeLessThanOrEqual(MODERATION_LIMITS.snapshotMaxBytes); + }); +}); diff --git a/packages/vitnode/src/api/lib/moderation/snapshot.ts b/packages/vitnode/src/api/lib/moderation/snapshot.ts new file mode 100644 index 0000000000..8c363b1873 --- /dev/null +++ b/packages/vitnode/src/api/lib/moderation/snapshot.ts @@ -0,0 +1,77 @@ +import type { + ModerationReportSnapshot, + ModerationSnapshotField, +} from "@/lib/moderation/snapshot"; + +import { richTextToPlainText } from "@/content/rich-text/plain-text"; +import { + MODERATION_LIMITS, + MODERATION_SNAPSHOT_VERSION, +} from "@/lib/moderation/const"; + +import type { ModerationCaptureResult } from "./targets"; + +const byteLength = (value: unknown): number => + Buffer.byteLength(JSON.stringify(value), "utf8"); + +const clip = (value: string, max: number): [string, boolean] => + value.length > max ? [value.slice(0, max), true] : [value, false]; + +export const sealReportSnapshot = ({ + capture, + capturedAt = new Date(), + target, +}: { + capture: ModerationCaptureResult; + capturedAt?: Date; + target: ModerationReportSnapshot["target"]; +}): ModerationReportSnapshot => { + let truncated = false; + const maxText = MODERATION_LIMITS.snapshotTextMaxLength; + + const fields: ModerationSnapshotField[] = capture.fields.map(field => { + if (field.kind === "text" && field.value !== null) { + const [value, cut] = clip(field.value, maxText); + truncated ||= cut; + + return { ...field, value }; + } + + return field; + }); + + const [title, titleCut] = + capture.title === null ? [null, false] : clip(capture.title, 500); + truncated ||= titleCut; + + const snapshot: ModerationReportSnapshot = { + ...capture, + capturedAt: capturedAt.toISOString(), + fields, + target, + title, + truncated, + version: MODERATION_SNAPSHOT_VERSION, + }; + + if (byteLength(snapshot) <= MODERATION_LIMITS.snapshotMaxBytes) { + return snapshot; + } + + snapshot.truncated = true; + snapshot.fields = snapshot.fields.map(field => { + if (field.kind !== "richText" || field.value === null) return field; + const [value] = clip(richTextToPlainText(field.value), maxText); + + return { kind: "text", label: field.label, name: field.name, value }; + }); + + while ( + snapshot.fields.length > 0 && + byteLength(snapshot) > MODERATION_LIMITS.snapshotMaxBytes + ) { + snapshot.fields = snapshot.fields.slice(0, -1); + } + + return snapshot; +}; diff --git a/packages/vitnode/src/api/lib/moderation/targets.ts b/packages/vitnode/src/api/lib/moderation/targets.ts new file mode 100644 index 0000000000..4b524de540 --- /dev/null +++ b/packages/vitnode/src/api/lib/moderation/targets.ts @@ -0,0 +1,86 @@ +import type { Context } from "hono"; + +import type { ContentDatabase } from "@/content/server/service"; +import type { ModerationTargetAction } from "@/lib/moderation/const"; +import type { ModerationReportSnapshot } from "@/lib/moderation/snapshot"; + +import { MODERATION_IDENTIFIER_PATTERN } from "@/lib/moderation/const"; + +import { ModerationRegistryError } from "./scopes"; + +export interface ModerationTargetState { + hidden: boolean; + url: null | string; +} + +export interface ModerationCaptureArgs { + locale: null | string; + targetId: string; +} + +export type ModerationCaptureResult = Omit< + ModerationReportSnapshot, + "capturedAt" | "target" | "truncated" | "version" +>; + +export interface ModerationActionArgs { + actorUserId: number; + targetId: string; + tx: ContentDatabase; +} + +export interface ModerationActionOutcome { + changed: boolean; + effects: () => Promise; +} + +export interface ModerationTargetActionDefinition { + available: (state: ModerationTargetState) => boolean; + permission: string; + run: ( + c: Context, + args: ModerationActionArgs, + ) => Promise; +} + +export interface ModerationReportTargetDefinition { + actions?: Partial< + Record + >; + capture: ( + c: Context, + args: ModerationCaptureArgs, + ) => Promise; + current: ( + c: Context, + args: { locale: null | string; targetId: string }, + ) => Promise; + label: string; + parseId: (value: string) => null | string; + permissionModule: string; + resolveScope?: (c: Context, targetId: string) => Promise; + scope?: string; + type: string; +} + +export interface RegisteredReportTarget { + definition: ModerationReportTargetDefinition; + pluginId: string; +} + +export const defineReportTarget = ( + definition: ModerationReportTargetDefinition, +): ModerationReportTargetDefinition => { + if (!MODERATION_IDENTIFIER_PATTERN.test(definition.type)) { + throw new ModerationRegistryError( + `Report target type "${definition.type}" must be lowercase and dot separated, like "forum.topic".`, + ); + } + if (definition.scope && !definition.resolveScope) { + throw new ModerationRegistryError( + `Report target "${definition.type}" is limited by the "${definition.scope}" scope, so it needs \`resolveScope\`.`, + ); + } + + return definition; +}; diff --git a/packages/vitnode/src/api/lib/permission-staff.ts b/packages/vitnode/src/api/lib/permission-staff.ts index 5b9d686b97..11b638a1d5 100644 --- a/packages/vitnode/src/api/lib/permission-staff.ts +++ b/packages/vitnode/src/api/lib/permission-staff.ts @@ -1,6 +1,10 @@ export type PermissionStaffEntryInput = | string - | { dependsOn?: string[]; permission: string }; + | { + dependsOn?: string[]; + permission: string; + scope?: string; + }; /** * Author-facing module map - what a plugin declares in its API config. @@ -13,6 +17,7 @@ export type PermissionStaffModulesInput = Record< export interface PermissionStaffEntry { dependsOn: string[]; permission: string; + scope?: string; } export type PermissionStaffModules = Record; @@ -33,15 +38,29 @@ export const normalizePermissionStaffModules = ( entries.map(entry => typeof entry === "string" ? { permission: entry, dependsOn: [] } - : { permission: entry.permission, dependsOn: entry.dependsOn ?? [] }, + : { + permission: entry.permission, + dependsOn: entry.dependsOn ?? [], + ...(entry.scope ? { scope: entry.scope } : {}), + }, ), ]), ); +export type PermissionStaffScope = + | { family: string; ids: string[]; mode: "selected" } + | { family: string; mode: "all" }; + export interface PermissionsStaffArgs { module: string; permission: string; plugin: string; + scope?: PermissionStaffScope; +} + +export interface PermissionStaffResource { + family: string; + id: string; } /** diff --git a/packages/vitnode/src/api/lib/plugin.ts b/packages/vitnode/src/api/lib/plugin.ts index f5d4deecbb..40f9c6b09f 100644 --- a/packages/vitnode/src/api/lib/plugin.ts +++ b/packages/vitnode/src/api/lib/plugin.ts @@ -15,12 +15,16 @@ import { validateContentTypes, withContentPermissions, } from "@/content/registry"; +import { contentDefinitionOf } from "@/content/server/model"; +import { contentReportTarget } from "@/content/server/report-target"; import type { SearchIndexer } from "../models/search"; import type { AnyAiActionDefinition } from "./ai/action"; import type { CronJobConfig } from "./cron"; import type { RegisteredEditablePage } from "./editable-pages"; import type { EventListenerConfig } from "./events"; +import type { ModerationPluginConfig } from "./moderation/registry"; +import type { ModerationReportTargetDefinition } from "./moderation/targets"; import type { BaseBuildModuleReturn, BuildModuleReturn } from "./module"; import type { AnyNotificationTypeDefinition } from "./notifications/registry"; import type { PermissionStaffConfig } from "./permission-staff"; @@ -31,6 +35,7 @@ import { validateSearchIndexers } from "../models/search"; import { collectAiActions } from "./ai/registry"; import { checkPluginId } from "./check-plugin-id"; import { registerEditablePage, validateEditablePages } from "./editable-pages"; +import { withModerationPermissions } from "./moderation/registry"; import { collectNavigationPresets } from "./navigation-presets"; import { validateNotificationTypes } from "./notifications/registry"; import { applyModuleTags } from "./openapi-tags"; @@ -51,6 +56,7 @@ export interface BuildPluginApiReturn< events?: Omit[]; hono: OpenAPIHono; messages?: LocaleMessagesMap; + moderation?: Required; modules: Modules; navigation?: NavigationPresetDeclaration[]; notificationTypes?: AnyNotificationTypeDefinition[]; @@ -77,6 +83,7 @@ export function buildApiPlugin< editablePages, pluginId, messages, + moderation, modules = [] as unknown as Modules, navigation, notificationTypes, @@ -87,6 +94,7 @@ export function buildApiPlugin< blocks?: BlockPluginSource; editablePages?: AnyEditablePageDefinition[]; messages?: LocaleMessagesMap; + moderation?: ModerationPluginConfig; modules?: Modules; navigation?: NavigationPresetDeclaration[]; notificationTypes?: AnyNotificationTypeDefinition[]; @@ -177,6 +185,13 @@ export function buildApiPlugin< } } + const reportTargets: ModerationReportTargetDefinition[] = [ + ...(moderation?.reportTargets ?? []), + ...contentModels + .filter(model => contentDefinitionOf(model).reports.enabled) + .map(model => contentReportTarget(model, pluginId)), + ]; + return { pluginId, aiActions, @@ -198,7 +213,11 @@ export function buildApiPlugin< webSockets, // Every content type contributes can_view/can_create/can_edit/can_delete // unless the plugin declared that module itself. - permissionStaff: withContentPermissions(permissionStaff, registered), + permissionStaff: withModerationPermissions( + withContentPermissions(permissionStaff, registered), + reportTargets, + ), + moderation: { reportTargets, scopes: moderation?.scopes ?? [] }, }; } diff --git a/packages/vitnode/src/api/lib/staff-permission-cache.ts b/packages/vitnode/src/api/lib/staff-permission-cache.ts index 074a43c894..eb350fae16 100644 --- a/packages/vitnode/src/api/lib/staff-permission-cache.ts +++ b/packages/vitnode/src/api/lib/staff-permission-cache.ts @@ -19,7 +19,7 @@ const readEpoch = async (c: Context): Promise => const permissionsKey = ( epoch: string, { type, userId }: { type: PermissionStaffType; userId: number }, -): string => `staff-permissions:v2:${epoch}:${type}:${userId}`; +): string => `staff-permissions:v3:${epoch}:${type}:${userId}`; /** The cached resolution, or `null` on a miss, without Redis, or on error. */ export const readStaffPermissions = async ( diff --git a/packages/vitnode/src/api/lib/staff-permission.test.ts b/packages/vitnode/src/api/lib/staff-permission.test.ts new file mode 100644 index 0000000000..d44fdcacde --- /dev/null +++ b/packages/vitnode/src/api/lib/staff-permission.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, it } from "vitest"; + +import type { + PermissionsStaffArgs, + PermissionStaffScope, + StaffPermissionSet, +} from "./permission-staff"; + +import { normalizePermissionStaffModules } from "./permission-staff"; +import { + applyScopedStaffDependencies, + hasStaffPermission, + holdsStaffPermission, + mergeStaffPermissionGrants, + staffPermissionResources, + staffScopeCovers, +} from "./staff-permission"; + +const PLUGIN = "@vitnode/forum"; +const MODULE = "topics"; +const FAMILY = "forum.section"; + +const VIEW = "can_view_reports"; +const MANAGE = "can_manage_reports"; +const VIEW_HIDDEN = "can_view_hidden"; +const HIDE = "can_hide"; + +const all = (family = FAMILY): PermissionStaffScope => ({ + family, + mode: "all", +}); +const only = (...ids: string[]): PermissionStaffScope => ({ + family: FAMILY, + ids, + mode: "selected", +}); + +const grant = ( + permission: string, + scope?: PermissionStaffScope, +): PermissionsStaffArgs => ({ + module: MODULE, + permission, + plugin: PLUGIN, + ...(scope ? { scope } : {}), +}); + +const set = (...permissions: PermissionsStaffArgs[]): StaffPermissionSet => ({ + permissions, + root: false, +}); + +const check = (permission: string, id?: string) => ({ + module: MODULE, + permission, + plugin: PLUGIN, + ...(id === undefined ? {} : { resource: { family: FAMILY, id } }), +}); + +const CATALOG = [ + { + moderator: normalizePermissionStaffModules({ + [MODULE]: [ + { permission: VIEW, scope: FAMILY }, + { dependsOn: [VIEW], permission: MANAGE, scope: FAMILY }, + { permission: VIEW_HIDDEN, scope: FAMILY }, + { dependsOn: [VIEW_HIDDEN], permission: HIDE, scope: FAMILY }, + ], + }), + pluginId: PLUGIN, + }, +]; + +describe("hasStaffPermission with a resource", () => { + it.each<[string, PermissionsStaffArgs, string | undefined, boolean]>([ + ["a selected grant, inside", grant(VIEW, only("a", "b")), "a", true], + ["a selected grant, outside", grant(VIEW, only("a")), "b", false], + [ + "a selected grant, no resource named", + grant(VIEW, only("a")), + undefined, + false, + ], + ["an all-resources grant, any resource", grant(VIEW, all()), "z", true], + [ + "an all-resources grant, no resource named", + grant(VIEW, all()), + undefined, + true, + ], + ["a legacy unscoped grant, any resource", grant(VIEW), "z", true], + [ + "a legacy unscoped grant, no resource named", + grant(VIEW), + undefined, + true, + ], + ])("%s", (_label, held, id, expected) => { + expect(hasStaffPermission(set(held), check(VIEW, id))).toBe(expected); + }); + + it("does not let a grant in one family answer for another family", () => { + expect( + hasStaffPermission( + set(grant(VIEW, all("blog.category"))), + check(VIEW, "a"), + ), + ).toBe(false); + }); + + it("answers yes to everything for root", () => { + expect( + hasStaffPermission({ permissions: [], root: true }, check(HIDE, "a")), + ).toBe(true); + }); +}); + +describe("holdsStaffPermission", () => { + it("is true for a permission held in any scope, without naming one", () => { + const held = set(grant(VIEW, only("a"))); + + expect(holdsStaffPermission(held, check(VIEW))).toBe(true); + expect(holdsStaffPermission(held, check(MANAGE))).toBe(false); + }); +}); + +describe("staffPermissionResources", () => { + it.each<[string, StaffPermissionSet, "all" | null | string[]]>([ + ["root", { permissions: [], root: true }, "all"], + ["a legacy unscoped grant", set(grant(VIEW)), "all"], + ["an all-resources grant", set(grant(VIEW, all())), "all"], + [ + "selected grants, merged and sorted", + set(grant(VIEW, only("c", "a")), grant(VIEW, only("b", "a"))), + ["a", "b", "c"], + ], + ["another permission only", set(grant(MANAGE, all())), null], + ["another family only", set(grant(VIEW, all("blog.category"))), null], + ["nothing", set(), null], + ])("answers %s", (_label, held, expected) => { + expect(staffPermissionResources(held, check(VIEW), FAMILY)).toEqual( + expected, + ); + }); +}); + +describe("staffScopeCovers", () => { + it.each< + [ + string, + PermissionStaffScope | undefined, + PermissionStaffScope | undefined, + boolean, + ] + >([ + ["unscoped covers anything", undefined, only("a"), true], + ["a scope never covers unscoped", all(), undefined, false], + ["all covers a selection", all(), only("a", "b"), true], + ["a selection never covers all", only("a", "b"), all(), false], + ["a superset covers a subset", only("a", "b"), only("b"), true], + ["a subset does not cover a superset", only("a"), only("a", "b"), false], + ["another family covers nothing", all("blog.category"), only("a"), false], + ])("%s", (_label, outer, inner, expected) => { + expect(staffScopeCovers(outer, inner)).toBe(expected); + }); +}); + +describe("mergeStaffPermissionGrants", () => { + it("unions the scopes of one permission granted by a user and a role", () => { + const merged = mergeStaffPermissionGrants([ + grant(VIEW, only("b")), + grant(VIEW, only("a", "b")), + ]); + + expect(merged).toEqual([grant(VIEW, only("a", "b"))]); + }); + + it.each<[string, PermissionsStaffArgs[], PermissionsStaffArgs[]]>([ + [ + "all absorbs a selection", + [grant(VIEW, only("a")), grant(VIEW, all())], + [grant(VIEW, all())], + ], + [ + "an unscoped grant absorbs a scoped one before it", + [grant(VIEW, only("a")), grant(VIEW)], + [grant(VIEW)], + ], + [ + "an unscoped grant absorbs a scoped one after it", + [grant(VIEW), grant(VIEW, only("a"))], + [grant(VIEW)], + ], + [ + "grants in different families stay apart", + [grant(VIEW, only("a")), grant(VIEW, all("blog.category"))], + [grant(VIEW, only("a")), grant(VIEW, all("blog.category"))], + ], + ])("%s", (_label, grants, expected) => { + expect(mergeStaffPermissionGrants(grants)).toEqual(expected); + }); + + it("never combines scopes across permissions", () => { + const merged = set( + ...mergeStaffPermissionGrants([ + grant(HIDE, only("a")), + grant(VIEW, only("b")), + ]), + ); + + expect(hasStaffPermission(merged, check(HIDE, "a"))).toBe(true); + expect(hasStaffPermission(merged, check(HIDE, "b"))).toBe(false); + expect(hasStaffPermission(merged, check(VIEW, "a"))).toBe(false); + expect(hasStaffPermission(merged, check(VIEW, "b"))).toBe(true); + }); +}); + +describe("applyScopedStaffDependencies", () => { + it.each<[string, PermissionsStaffArgs[], PermissionsStaffArgs[]]>([ + [ + "narrows a grant to where its dependency is held", + [grant(VIEW, only("a")), grant(MANAGE, only("a", "b"))], + [grant(VIEW, only("a")), grant(MANAGE, only("a"))], + ], + [ + "narrows an all-resources grant to the dependency's selection", + [grant(VIEW, only("a")), grant(MANAGE, all())], + [grant(VIEW, only("a")), grant(MANAGE, only("a"))], + ], + [ + "drops a grant whose dependency is held nowhere it applies", + [grant(VIEW, only("b")), grant(MANAGE, only("a"))], + [grant(VIEW, only("b"))], + ], + [ + "drops a grant whose dependency is missing", + [grant(MANAGE, only("a", "b"))], + [], + ], + [ + "keeps a grant whose dependency is unscoped", + [grant(VIEW), grant(MANAGE, only("a"))], + [grant(VIEW), grant(MANAGE, only("a"))], + ], + ["leaves an unscoped grant alone", [grant(MANAGE)], [grant(MANAGE)]], + ])("%s", (_label, grants, expected) => { + expect(applyScopedStaffDependencies(grants, CATALOG)).toEqual(expected); + }); + + it("follows each permission's own dependency, not another's", () => { + const narrowed = applyScopedStaffDependencies( + [ + grant(VIEW, all()), + grant(VIEW_HIDDEN, only("a")), + grant(HIDE, only("a", "b")), + ], + CATALOG, + ); + + expect(narrowed).toContainEqual(grant(HIDE, only("a"))); + }); +}); diff --git a/packages/vitnode/src/api/lib/staff-permission.ts b/packages/vitnode/src/api/lib/staff-permission.ts index 30cf9d884c..1aa882f0a4 100644 --- a/packages/vitnode/src/api/lib/staff-permission.ts +++ b/packages/vitnode/src/api/lib/staff-permission.ts @@ -1,27 +1,224 @@ import type { PermissionsStaffArgs, + PermissionStaffModules, + PermissionStaffResource, + PermissionStaffScope, StaffPermissionSet, } from "./permission-staff"; +export type StaffPermissionCheck = Omit & { + resource?: PermissionStaffResource; +}; + +const sameKey = ( + grant: Omit, + args: Omit, +): boolean => + grant.plugin === args.plugin && + grant.module === args.module && + grant.permission === args.permission; + +const grantAllows = ( + grant: PermissionsStaffArgs, + resource: PermissionStaffResource | undefined, +): boolean => { + if (!grant.scope) return true; + if (resource && resource.family !== grant.scope.family) return false; + if (grant.scope.mode === "all") return true; + + return resource !== undefined && grant.scope.ids.includes(resource.id); +}; + export const hasStaffPermission = ( set: StaffPermissionSet, - args: PermissionsStaffArgs, + { resource, ...args }: StaffPermissionCheck, ): boolean => { if (set.root) return true; return set.permissions.some( - permission => - permission.plugin === args.plugin && - permission.module === args.module && - permission.permission === args.permission, + grant => sameKey(grant, args) && grantAllows(grant, resource), ); }; +export const holdsStaffPermission = ( + set: StaffPermissionSet, + args: Omit, +): boolean => set.root || set.permissions.some(grant => sameKey(grant, args)); + +export const staffPermissionResources = ( + set: StaffPermissionSet, + args: Omit, + family: string, +): "all" | null | string[] => { + if (set.root) return "all"; + + const ids = new Set(); + for (const grant of set.permissions) { + if (!sameKey(grant, args)) continue; + if (!grant.scope) return "all"; + if (grant.scope.family !== family) continue; + if (grant.scope.mode === "all") return "all"; + for (const id of grant.scope.ids) ids.add(id); + } + + return ids.size > 0 ? [...ids].sort() : null; +}; + export const staffPermissionKey = ({ plugin, module, permission, -}: PermissionsStaffArgs): string => `${plugin}:${module}:${permission}`; +}: Omit): string => + `${plugin}:${module}:${permission}`; + +export const staffScopeCovers = ( + outer: PermissionStaffScope | undefined, + inner: PermissionStaffScope | undefined, +): boolean => { + if (!outer) return true; + if (!inner) return false; + if (outer.family !== inner.family) return false; + if (outer.mode === "all") return true; + if (inner.mode === "all") return false; + + const allowed = new Set(outer.ids); + + return inner.ids.every(id => allowed.has(id)); +}; + +const unionScopes = ( + a: PermissionStaffScope, + b: PermissionStaffScope, +): PermissionStaffScope => { + if (a.mode === "all") return a; + if (b.mode === "all") return b; + + return { + family: a.family, + ids: [...new Set([...a.ids, ...b.ids])].sort(), + mode: "selected", + }; +}; + +export const mergeStaffPermissionGrants = ( + grants: readonly PermissionsStaffArgs[], +): PermissionsStaffArgs[] => { + const merged = new Map(); + + for (const grant of grants) { + const key = staffPermissionKey(grant); + const base = { + module: grant.module, + permission: grant.permission, + plugin: grant.plugin, + }; + const unscoped = merged.get(key); + if (unscoped && !unscoped.scope) continue; + + if (!grant.scope) { + for (const existing of merged.keys()) { + if (existing.startsWith(`${key}|`)) merged.delete(existing); + } + merged.set(key, base); + continue; + } + + const familyKey = `${key}|${grant.scope.family}`; + const existing = merged.get(familyKey); + merged.set(familyKey, { + ...base, + scope: existing?.scope + ? unionScopes(existing.scope, grant.scope) + : grant.scope, + }); + } + + return [...merged.values()]; +}; + +const intersectScopes = ( + a: PermissionStaffScope, + b: PermissionStaffScope | undefined, +): null | PermissionStaffScope => { + if (!b) return a; + if (a.family !== b.family) return b.mode === "all" ? a : null; + if (b.mode === "all") return a; + if (a.mode === "all") return b; + + const allowed = new Set(b.ids); + const ids = a.ids.filter(id => allowed.has(id)).sort(); + + return ids.length > 0 ? { family: a.family, ids, mode: "selected" } : null; +}; + +export const applyScopedStaffDependencies = ( + grants: readonly PermissionsStaffArgs[], + catalog: readonly { moderator: PermissionStaffModules; pluginId: string }[], +): PermissionsStaffArgs[] => { + const dependencies = new Map(); + for (const plugin of catalog) { + for (const [module, entries] of Object.entries(plugin.moderator)) { + for (const entry of entries) { + dependencies.set( + staffPermissionKey({ + module, + permission: entry.permission, + plugin: plugin.pluginId, + }), + entry.dependsOn, + ); + } + } + } + + let current = [...grants]; + let changed = true; + while (changed) { + changed = false; + const next: PermissionsStaffArgs[] = []; + for (const grant of current) { + if (!grant.scope) { + next.push(grant); + continue; + } + + let scope: null | PermissionStaffScope = grant.scope; + for (const dependency of dependencies.get(staffPermissionKey(grant)) ?? + []) { + if (!scope) break; + const held = current.filter( + other => + other.plugin === grant.plugin && + other.module === grant.module && + other.permission === dependency, + ); + if (held.length === 0) { + scope = null; + break; + } + if (held.some(other => !other.scope)) continue; + const narrowing: PermissionStaffScope = scope; + let widest: null | PermissionStaffScope = null; + for (const other of held) { + const overlap = intersectScopes(narrowing, other.scope); + if (!overlap) continue; + widest = widest ? unionScopes(widest, overlap) : overlap; + } + scope = widest; + } + + if (!scope) { + changed = true; + continue; + } + if (JSON.stringify(scope) !== JSON.stringify(grant.scope)) changed = true; + next.push({ ...grant, scope }); + } + current = next; + } + + return current; +}; /** * Nobody's permissions - the answer when there is no admin session to read one diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts index eb2c8805fc..13e1ce0591 100644 --- a/packages/vitnode/src/api/middlewares/global.middleware.ts +++ b/packages/vitnode/src/api/middlewares/global.middleware.ts @@ -44,6 +44,7 @@ import type { AiActionRegistry } from "../lib/ai/registry"; import type { BuildCronReturn } from "../lib/cron"; import type { RegisteredEditablePage } from "../lib/editable-pages"; import type { EventListenerConfig } from "../lib/events"; +import type { ModerationRegistry } from "../lib/moderation/registry"; import type { NotificationWorkerSettings } from "../lib/notifications/preferences"; import type { NotificationRegistry } from "../lib/notifications/registry"; import type { ResolvedPasskeysConfig } from "../lib/passkey-config"; @@ -70,6 +71,7 @@ import { loggerMiddleware, type LoggerMiddlewareType, } from "../lib/logger-middleware"; +import { validateModerationRegistry } from "../lib/moderation/registry"; import { collectNavigationPresets } from "../lib/navigation-presets"; import { resolveNotificationWorkerSettings } from "../lib/notifications/preferences"; import { createNotificationRegistry } from "../lib/notifications/registry"; @@ -157,6 +159,7 @@ export interface EnvVariablesVitNode { shortTitle?: string; title: string; }; + moderation: ModerationRegistry; /** Every prebuilt main-menu item the installed plugins offer. */ navigation: NavigationPreset[]; notifications?: NotificationRegistry; @@ -420,6 +423,22 @@ export const globalMiddleware = ({ permissionStaff: permissionStaffMetadata, }); + const moderationMetadata: ModerationRegistry = validateModerationRegistry({ + catalog: permissionStaffMetadata, + scopes: plugins.flatMap(plugin => + (plugin.moderation?.scopes ?? []).map(definition => ({ + definition, + pluginId: plugin.pluginId, + })), + ), + targets: plugins.flatMap(plugin => + (plugin.moderation?.reportTargets ?? []).map(definition => ({ + definition, + pluginId: plugin.pluginId, + })), + ), + }); + return async (c: Context, next: Next) => { if (!c.get("ipAddress")) { c.set("ipAddress", resolveClientIp(c)); @@ -486,6 +505,7 @@ export const globalMiddleware = ({ queue: queueMetadata, webSockets: webSocketsMetadata, permissionStaff: permissionStaffMetadata, + moderation: moderationMetadata, navigation: navigationMetadata, notifications: notificationsMetadata, notificationWorkers, diff --git a/packages/vitnode/src/api/models/events.ts b/packages/vitnode/src/api/models/events.ts index 6c75ebb158..4a78722a43 100644 --- a/packages/vitnode/src/api/models/events.ts +++ b/packages/vitnode/src/api/models/events.ts @@ -30,6 +30,40 @@ export interface VitNodeEvents { languageCodes: string[]; origin: "ai" | "human"; }; + "moderation.report.action": { + action: "hide" | "unhide"; + pluginId: string; + reportId: number; + targetId: string; + targetType: string; + }; + "moderation.report.assigned": { + assigneeId: null | number; + previousAssigneeId: null | number; + reportId: number; + }; + "moderation.report.created": { + pluginId: string; + reason: string; + reportId: number; + targetId: string; + targetType: string; + }; + "moderation.report.note_deleted": { + noteId: number; + reportId: number; + }; + "moderation.report.note_updated": { + noteId: number; + reportId: number; + }; + "moderation.report.reopened": { + reportId: number; + }; + "moderation.report.resolved": { + reportId: number; + resolution: string; + }; "navigation.created": { navigationId: number; }; diff --git a/packages/vitnode/src/api/moderation.ts b/packages/vitnode/src/api/moderation.ts new file mode 100644 index 0000000000..797d8ff71e --- /dev/null +++ b/packages/vitnode/src/api/moderation.ts @@ -0,0 +1,19 @@ +export { syncModerationReportScope } from "./lib/moderation/access"; +export type { ModerationPluginConfig } from "./lib/moderation/registry"; +export { + defineModerationScope, + type ModerationScopeDefinition, + type ModerationScopeListArgs, + type ModerationScopeListResult, + type ModerationScopeResource, +} from "./lib/moderation/scopes"; +export { + defineReportTarget, + type ModerationActionArgs, + type ModerationActionOutcome, + type ModerationCaptureArgs, + type ModerationCaptureResult, + type ModerationReportTargetDefinition, + type ModerationTargetActionDefinition, + type ModerationTargetState, +} from "./lib/moderation/targets"; diff --git a/packages/vitnode/src/api/modules/admin/staff/lib/schema.ts b/packages/vitnode/src/api/modules/admin/staff/lib/schema.ts index bda31629de..9e50fc4358 100644 --- a/packages/vitnode/src/api/modules/admin/staff/lib/schema.ts +++ b/packages/vitnode/src/api/modules/admin/staff/lib/schema.ts @@ -51,10 +51,36 @@ export const staffListAdminSchema = z.object({ pageInfo: zodPaginationPageInfo, }); +export const permissionStaffScopeSchema = z.discriminatedUnion("mode", [ + z.object({ family: z.string().max(100), mode: z.literal("all") }), + z.object({ + family: z.string().max(100), + mode: z.literal("selected"), + ids: z.array(z.string().max(64)).max(500), + }), +]); + export const permissionsStaffArgsSchema = z.object({ plugin: z.string(), module: z.string(), permission: z.string(), + scope: permissionStaffScopeSchema.optional(), +}); + +export const staffGrantIssueSchema = z.object({ + key: z.string(), + type: z.enum([ + "dependency_scope", + "empty_selection", + "invalid_resource", + "missing_scope", + "unexpected_scope", + "unknown_resource", + "wrong_family", + ]), + dependency: z.string().optional(), + expected: z.string().optional(), + ids: z.array(z.string()).optional(), }); export const staffTypeSchema = z.enum(["admin", "moderator"]); diff --git a/packages/vitnode/src/api/modules/admin/staff/lib/scoped-grants.ts b/packages/vitnode/src/api/modules/admin/staff/lib/scoped-grants.ts new file mode 100644 index 0000000000..47546a9cba --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/staff/lib/scoped-grants.ts @@ -0,0 +1,202 @@ +import type { Context } from "hono"; + +import { HTTPException } from "hono/http-exception"; + +import type { + PermissionsStaffArgs, + PermissionStaffCatalogEntry, + PermissionStaffType, +} from "@/api/lib/permission-staff"; + +import { findModerationScope } from "@/api/lib/moderation/registry"; +import { isModerationResourceId } from "@/api/lib/moderation/scopes"; +import { + staffPermissionKey, + staffScopeCovers, +} from "@/api/lib/staff-permission"; + +export type StaffGrantIssue = + | { dependency: string; key: string; type: "dependency_scope" } + | { expected: string; key: string; type: "wrong_family" } + | { ids: string[]; key: string; type: "invalid_resource" } + | { ids: string[]; key: string; type: "unknown_resource" } + | { key: string; type: "empty_selection" } + | { key: string; type: "missing_scope" } + | { key: string; type: "unexpected_scope" }; + +export class StaffGrantValidationError extends HTTPException { + constructor(issues: StaffGrantIssue[]) { + super(400, { + message: "The submitted permissions are not valid.", + res: Response.json( + { error: "invalid_permissions", issues }, + { status: 400 }, + ), + }); + this.issues = issues; + } + + readonly issues: StaffGrantIssue[]; +} + +interface CatalogEntry { + dependsOn: string[]; + scope?: string; +} + +const catalogIndex = ( + catalog: readonly PermissionStaffCatalogEntry[], + type: PermissionStaffType, +) => { + const index = new Map(); + for (const plugin of catalog) { + for (const [module, entries] of Object.entries(plugin[type])) { + for (const entry of entries) { + index.set( + staffPermissionKey({ + module, + permission: entry.permission, + plugin: plugin.pluginId, + }), + { + dependsOn: entry.dependsOn.map(dependency => + staffPermissionKey({ + module, + permission: dependency, + plugin: plugin.pluginId, + }), + ), + scope: entry.scope, + }, + ); + } + } + } + + return index; +}; + +export const sanitizeStaffGrants = async ( + c: Context, + { + permissions, + type, + }: { permissions: PermissionsStaffArgs[]; type: PermissionStaffType }, +): Promise => { + const index = catalogIndex(c.get("core").permissionStaff, type); + const registry = c.get("core").moderation; + const issues: StaffGrantIssue[] = []; + + const granted = new Map(); + for (const permission of permissions) { + const key = staffPermissionKey(permission); + const entry = index.get(key); + if (!entry || granted.has(key)) continue; + + const base = { + module: permission.module, + permission: permission.permission, + plugin: permission.plugin, + }; + + if (!entry.scope) { + if (permission.scope) issues.push({ key, type: "unexpected_scope" }); + granted.set(key, base); + continue; + } + + const scope = permission.scope; + if (!scope) { + issues.push({ key, type: "missing_scope" }); + continue; + } + if (scope.family !== entry.scope) { + issues.push({ expected: entry.scope, key, type: "wrong_family" }); + continue; + } + if (scope.mode === "all") { + granted.set(key, { + ...base, + scope: { family: scope.family, mode: "all" }, + }); + continue; + } + + const ids = [...new Set(scope.ids)]; + if (ids.length === 0) { + issues.push({ key, type: "empty_selection" }); + continue; + } + const malformed = ids.filter(id => !isModerationResourceId(id)); + if (malformed.length > 0) { + issues.push({ ids: malformed, key, type: "invalid_resource" }); + continue; + } + granted.set(key, { + ...base, + scope: { family: scope.family, ids: ids.sort(), mode: "selected" }, + }); + } + + const selectedByFamily = new Map>(); + for (const grant of granted.values()) { + if (grant.scope?.mode !== "selected") continue; + const ids = selectedByFamily.get(grant.scope.family) ?? new Set(); + for (const id of grant.scope.ids) ids.add(id); + selectedByFamily.set(grant.scope.family, ids); + } + for (const [family, ids] of selectedByFamily) { + const scope = findModerationScope(registry, family); + const found = new Set( + scope + ? (await scope.definition.find(c, [...ids])).map(item => item.id) + : [], + ); + for (const [key, grant] of granted) { + if (grant.scope?.mode !== "selected" || grant.scope.family !== family) { + continue; + } + const missing = grant.scope.ids.filter(id => !found.has(id)); + if (missing.length > 0) { + issues.push({ ids: missing, key, type: "unknown_resource" }); + } + } + } + + let changed = true; + while (changed) { + changed = false; + for (const key of [...granted.keys()]) { + const dependencies = index.get(key)?.dependsOn ?? []; + if (dependencies.some(dependency => !granted.has(dependency))) { + granted.delete(key); + changed = true; + } + } + } + + for (const [key, grant] of granted) { + for (const dependency of index.get(key)?.dependsOn ?? []) { + const held = granted.get(dependency); + if (held && !staffScopeCovers(held.scope, grant.scope)) { + issues.push({ dependency, key, type: "dependency_scope" }); + } + } + } + + if (issues.length > 0) throw new StaffGrantValidationError(issues); + + return [...granted.values()]; +}; + +export const staffGrantsWithinCeiling = ( + requested: readonly PermissionsStaffArgs[], + ceiling: readonly PermissionsStaffArgs[], +): boolean => + requested.every(grant => + ceiling.some( + held => + staffPermissionKey(held) === staffPermissionKey(grant) && + staffScopeCovers(held.scope, grant.scope), + ), + ); diff --git a/packages/vitnode/src/api/modules/admin/staff/lib/staff-entry-guards.ts b/packages/vitnode/src/api/modules/admin/staff/lib/staff-entry-guards.ts index 56e2f76380..55d7cc0087 100644 --- a/packages/vitnode/src/api/modules/admin/staff/lib/staff-entry-guards.ts +++ b/packages/vitnode/src/api/modules/admin/staff/lib/staff-entry-guards.ts @@ -13,9 +13,10 @@ import { getUserRoleIds, resolveStaffPermissions, } from "@/api/lib/check-staff-permission"; -import { staffPermissionKey } from "@/api/lib/staff-permission"; import { core_roles } from "@/database/roles"; +import { staffGrantsWithinCeiling } from "./scoped-grants"; + interface StaffEntryTarget { roleId?: null | number; userId?: null | number; @@ -77,13 +78,7 @@ interface StaffEntryGrant { const exceedsCeiling = ( permissions: PermissionsStaffArgs[], ceiling: PermissionsStaffArgs[], -): boolean => { - const allowed = new Set(ceiling.map(staffPermissionKey)); - - return permissions.some( - permission => !allowed.has(staffPermissionKey(permission)), - ); -}; +): boolean => !staffGrantsWithinCeiling(permissions, ceiling); export const assertCanManageStaffEntry = async ( c: Context, diff --git a/packages/vitnode/src/api/modules/admin/staff/routes/permission-catalog.route.ts b/packages/vitnode/src/api/modules/admin/staff/routes/permission-catalog.route.ts index 98e84721fe..f48c8ba389 100644 --- a/packages/vitnode/src/api/modules/admin/staff/routes/permission-catalog.route.ts +++ b/packages/vitnode/src/api/modules/admin/staff/routes/permission-catalog.route.ts @@ -9,10 +9,17 @@ const permissionStaffModulesSchema = z.record( z.object({ permission: z.string(), dependsOn: z.array(z.string()), + scope: z.string().optional(), }), ), ); +const scopeFamilySchema = z.object({ + id: z.string(), + label: z.string(), + description: z.string().nullable(), +}); + export const permissionCatalogStaffAdminRoute = buildRoute({ pluginId: CONFIG_PLUGIN.pluginId, route: { @@ -29,11 +36,13 @@ export const permissionCatalogStaffAdminRoute = buildRoute({ pluginId: z.string(), admin: permissionStaffModulesSchema, moderator: permissionStaffModulesSchema, + scopes: z.array(scopeFamilySchema), }), ), }, }, - description: "Staff permission catalog", + description: + "Staff permission catalog, with the moderation scope families each plugin registers", }, 403: { description: "Access Denied", @@ -41,6 +50,20 @@ export const permissionCatalogStaffAdminRoute = buildRoute({ }, }, handler: c => { - return c.json(c.get("core").permissionStaff, 200); + const { moderation, permissionStaff } = c.get("core"); + + return c.json( + permissionStaff.map(plugin => ({ + ...plugin, + scopes: (moderation?.scopes ?? []) + .filter(scope => scope.pluginId === plugin.pluginId) + .map(({ definition }) => ({ + id: definition.id, + label: definition.label, + description: definition.description ?? null, + })), + })), + 200, + ); }, }); diff --git a/packages/vitnode/src/api/modules/admin/staff/routes/scope-resources.route.ts b/packages/vitnode/src/api/modules/admin/staff/routes/scope-resources.route.ts new file mode 100644 index 0000000000..c23fa2e6d0 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/staff/routes/scope-resources.route.ts @@ -0,0 +1,98 @@ +import { z } from "@hono/zod-openapi"; + +import { findModerationScope } from "@/api/lib/moderation/registry"; +import { isModerationResourceId } from "@/api/lib/moderation/scopes"; +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; + +import { staffPermissionModuleByType } from "../lib/schema"; + +const resourceSchema = z.object({ + id: z.string(), + label: z.string(), + context: z.array(z.string()), +}); + +const PAGE_SIZE = 20; + +export const scopeResourcesStaffAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { + module: staffPermissionModuleByType.moderator, + permission: "can_edit", + }, + route: { + method: "get", + description: + "List the resources of one moderation scope family for the moderator editor (Admin only)", + path: "/scope-resources/{family}", + request: { + params: z.object({ family: z.string().max(100) }), + query: z.object({ + search: z.string().max(100).optional(), + cursor: z.string().max(200).optional(), + ids: z.string().max(4000).optional(), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + items: z.array(resourceSchema), + missing: z.array(z.string()), + nextCursor: z.string().nullable(), + }), + }, + }, + description: "One page of resources, or the requested ones", + }, + 403: { description: "Access Denied" }, + 404: { description: "Unknown scope family" }, + }, + }, + handler: async c => { + const { family } = c.req.valid("param"); + const scope = findModerationScope(c.get("core").moderation, family); + if (!scope) return c.json({ error: "Unknown scope family" }, 404); + + const { cursor, ids, search } = c.req.valid("query"); + const normalize = (item: { + context?: string[]; + id: string; + label: string; + }) => ({ id: item.id, label: item.label, context: item.context ?? [] }); + + if (ids !== undefined) { + const wanted = [ + ...new Set(ids.split(",").filter(id => isModerationResourceId(id))), + ].slice(0, 200); + const found = wanted.length ? await scope.definition.find(c, wanted) : []; + const foundIds = new Set(found.map(item => item.id)); + + return c.json( + { + items: found.map(normalize), + missing: wanted.filter(id => !foundIds.has(id)), + nextCursor: null, + }, + 200, + ); + } + + const page = await scope.definition.list(c, { + cursor: cursor ?? null, + limit: PAGE_SIZE, + search: search?.trim() === "" ? undefined : search?.trim(), + }); + + return c.json( + { + items: page.items.slice(0, PAGE_SIZE).map(normalize), + missing: [], + nextCursor: page.nextCursor, + }, + 200, + ); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/staff/routes/scoped-permissions.test.ts b/packages/vitnode/src/api/modules/admin/staff/routes/scoped-permissions.test.ts new file mode 100644 index 0000000000..deb45691b4 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/staff/routes/scoped-permissions.test.ts @@ -0,0 +1,239 @@ +import type { Context } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { describe, expect, it } from "vitest"; + +import type { + PermissionsStaffArgs, + PermissionStaffScope, + StaffPermissionSet, +} from "@/api/lib/permission-staff"; + +import { defineModerationScope } from "@/api/lib/moderation/scopes"; +import { normalizePermissionStaffModules } from "@/api/lib/permission-staff"; +import { core_moderators_permissions } from "@/database/moderators"; +import { core_roles } from "@/database/roles"; +import { core_users_secondary_roles } from "@/database/users"; +import { createTestCache } from "@/tests/cache"; +import { createMemoryDb } from "@/tests/memory-db"; +import { + grantStaffPermissions, + ROOT_STAFF_PERMISSIONS, +} from "@/tests/staff-permissions"; + +import { updatePermissionsStaffAdminRoute } from "./update-permissions.route"; + +const FORUM = "@vitnode/forum"; +const FAMILY = "forum.section"; +const SECTION_A = "10"; +const SECTION_B = "20"; +const SECTION_C = "30"; +const SECTIONS = [ + { id: SECTION_A, label: "General" }, + { id: SECTION_B, label: "Off-topic" }, + { id: SECTION_C, label: "Support" }, +]; + +const CATALOG = [ + { + admin: {}, + moderator: normalizePermissionStaffModules({ + topics: [ + { permission: "can_view_reports", scope: FAMILY }, + { + dependsOn: ["can_view_reports"], + permission: "can_manage_reports", + scope: FAMILY, + }, + "can_pin", + ], + }), + pluginId: FORUM, + }, +]; + +const SECTION_SCOPE = defineModerationScope({ + find: async (_c, ids) => + Promise.resolve(SECTIONS.filter(section => ids.includes(section.id))), + id: FAMILY, + label: "@vitnode/forum.scopes.section", + list: async () => Promise.resolve({ items: SECTIONS, nextCursor: null }), +}); + +const selected = (...ids: string[]): PermissionStaffScope => ({ + family: FAMILY, + ids, + mode: "selected", +}); +const everywhere: PermissionStaffScope = { family: FAMILY, mode: "all" }; + +const topics = ( + permission: string, + scope?: PermissionStaffScope, +): PermissionsStaffArgs => ({ + module: "topics", + permission, + plugin: FORUM, + ...(scope ? { scope } : {}), +}); + +const CALLER = { id: 1, roleId: 10 }; +const TARGET_ROLE_ID = 20; +const ENTRY = { + id: 5, + permissions: [], + protected: false, + roleId: TARGET_ROLE_ID, + unrestricted: false, + userId: null, +}; +const EDIT_MODERATORS: PermissionsStaffArgs = { + module: "staff_moderators", + permission: "can_edit", + plugin: "@vitnode/core", +}; + +const appFor = async ( + moderator: + | PermissionsStaffArgs[] + | StaffPermissionSet = ROOT_STAFF_PERMISSIONS, +) => { + const cache = createTestCache(); + await grantStaffPermissions(cache, { + permissions: [EDIT_MODERATORS], + userId: CALLER.id, + }); + await grantStaffPermissions(cache, { + permissions: moderator, + type: "moderator", + userId: CALLER.id, + }); + const memory = createMemoryDb([ + [ + core_roles, + [ + { default: false, guest: false, id: CALLER.roleId, root: false }, + { default: false, guest: false, id: TARGET_ROLE_ID, root: false }, + ], + ], + [core_users_secondary_roles, []], + [core_moderators_permissions, [ENTRY]], + ]); + const app = new OpenAPIHono(); + app.use("*", async (c, next) => { + c.set("admin", { user: CALLER } as unknown as Context["var"]["admin"]); + c.set("cache", cache); + c.set("db", memory.db as unknown as Context["var"]["db"]); + c.set("core", { + moderation: { + scopes: [{ definition: SECTION_SCOPE, pluginId: FORUM }], + targets: [], + }, + permissionStaff: CATALOG, + } as unknown as Context["var"]["core"]); + await next(); + }); + app.openapi( + updatePermissionsStaffAdminRoute.route, + updatePermissionsStaffAdminRoute.handler, + ); + + const save = async (permissions: PermissionsStaffArgs[]) => + await app.request(`/entry/moderator/${ENTRY.id}`, { + body: JSON.stringify({ permissions, unrestricted: false }), + headers: { "Content-Type": "application/json" }, + method: "PATCH", + }); + const stored = () => memory.rows(core_moderators_permissions)[0]?.permissions; + + return { save, stored }; +}; + +const VIEW_KEY = `${FORUM}:topics:can_view_reports`; +const MANAGE_KEY = `${FORUM}:topics:can_manage_reports`; + +describe("saving scoped moderator permissions", () => { + it.each<[string, PermissionsStaffArgs[], Record]>([ + [ + "a scoped permission without a scope", + [topics("can_view_reports")], + { key: VIEW_KEY, type: "missing_scope" }, + ], + [ + "a section id that does not exist", + [topics("can_view_reports", selected(SECTION_A, "999"))], + { ids: ["999"], key: VIEW_KEY, type: "unknown_resource" }, + ], + [ + "an empty selection", + [topics("can_view_reports", selected())], + { key: VIEW_KEY, type: "empty_selection" }, + ], + [ + "managing where the moderator cannot view", + [ + topics("can_view_reports", selected(SECTION_A)), + topics("can_manage_reports", selected(SECTION_B)), + ], + { dependency: VIEW_KEY, key: MANAGE_KEY, type: "dependency_scope" }, + ], + [ + "a scope on an unscoped permission", + [topics("can_pin", everywhere)], + { key: `${FORUM}:topics:can_pin`, type: "unexpected_scope" }, + ], + ])("refuses %s", async (_label, permissions, issue) => { + const { save, stored } = await appFor(); + + const res = await save(permissions); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "invalid_permissions", + issues: [issue], + }); + expect(stored()).toEqual([]); + }); + + it("stores a valid selection with its ids sorted and de-duplicated", async () => { + const { save, stored } = await appFor(); + + const res = await save([ + topics("can_view_reports", selected(SECTION_C, SECTION_A, SECTION_C)), + topics("can_manage_reports", selected(SECTION_A)), + ]); + + expect(res.status).toBe(200); + expect(stored()).toEqual([ + topics("can_view_reports", selected(SECTION_A, SECTION_C)), + topics("can_manage_reports", selected(SECTION_A)), + ]); + }); + + it("measures delegation against the resources the caller holds", async () => { + const callerViewsA = [topics("can_view_reports", selected(SECTION_A))]; + + const wider = await appFor(callerViewsA); + expect( + ( + await wider.save([ + topics("can_view_reports", selected(SECTION_A, SECTION_B)), + ]) + ).status, + ).toBe(403); + expect(wider.stored()).toEqual([]); + + const everywhereRequest = await appFor(callerViewsA); + expect( + (await everywhereRequest.save([topics("can_view_reports", everywhere)])) + .status, + ).toBe(403); + + const within = await appFor(callerViewsA); + expect( + (await within.save([topics("can_view_reports", selected(SECTION_A))])) + .status, + ).toBe(200); + expect(within.stored()).toEqual(callerViewsA); + }); +}); diff --git a/packages/vitnode/src/api/modules/admin/staff/routes/update-permissions.route.ts b/packages/vitnode/src/api/modules/admin/staff/routes/update-permissions.route.ts index 84df835ff0..1baaaccce0 100644 --- a/packages/vitnode/src/api/modules/admin/staff/routes/update-permissions.route.ts +++ b/packages/vitnode/src/api/modules/admin/staff/routes/update-permissions.route.ts @@ -6,7 +6,6 @@ import type { PermissionsStaffArgs } from "@/api/lib/permission-staff"; import { assertStaffPermission } from "@/api/lib/check-staff-permission"; import { buildRoute } from "@/api/lib/route"; -import { staffPermissionKey } from "@/api/lib/staff-permission"; import { invalidateStaffEntry } from "@/api/lib/staff-permission-cache"; import { CONFIG_PLUGIN } from "@/config"; import { core_admin_permissions } from "@/database/admins"; @@ -14,9 +13,11 @@ import { core_moderators_permissions } from "@/database/moderators"; import { permissionsStaffArgsSchema, + staffGrantIssueSchema, staffPermissionModuleByType, staffTypeSchema, } from "../lib/schema"; +import { sanitizeStaffGrants } from "../lib/scoped-grants"; import { assertNotOwnStaffEntry, assertStaffAssignableRole, @@ -63,7 +64,16 @@ export const updatePermissionsStaffAdminRoute = buildRoute({ description: "Updated permissions", }, 400: { - description: "The default and guest roles cannot be given staff access", + content: { + "application/json": { + schema: z.object({ + error: z.string(), + issues: z.array(staffGrantIssueSchema), + }), + }, + }, + description: + "A scope is missing, malformed, names unknown resources, or is wider than its dependency's. The default and guest roles cannot be given staff access either", }, 403: { description: "Access Denied", @@ -136,62 +146,9 @@ export const updatePermissionsStaffAdminRoute = buildRoute({ requested: { unrestricted, permissions }, }); - // Only persist permissions that actually exist in the catalog for this - // staff type - silently drops anything unknown/forged. Also record each - // permission's dependencies (the keys of the permissions it `dependsOn` - // within the same module) so we can drop grants whose gate is missing. - const allowed = new Set(); - const dependencies = new Map(); - for (const plugin of c.get("core").permissionStaff) { - for (const [module, modulePermissions] of Object.entries(plugin[type])) { - for (const entry of modulePermissions) { - const key = staffPermissionKey({ - plugin: plugin.pluginId, - module, - permission: entry.permission, - }); - allowed.add(key); - dependencies.set( - key, - entry.dependsOn.map(dependency => - staffPermissionKey({ - plugin: plugin.pluginId, - module, - permission: dependency, - }), - ), - ); - } - } - } - - const seen = new Set(); - const granted = new Map(); - // When unrestricted, the explicit list is irrelevant - store none. - if (!unrestricted) { - for (const permission of permissions) { - const key = staffPermissionKey(permission); - if (!allowed.has(key) || seen.has(key)) continue; - seen.add(key); - granted.set(key, permission); - } - - // Drop any granted permission whose dependencies aren't all granted too, - // repeating until stable so a broken chain (a → b → c) collapses fully. - let changed = true; - while (changed) { - changed = false; - for (const key of granted.keys()) { - const deps = dependencies.get(key) ?? []; - if (deps.some(dependency => !granted.has(dependency))) { - granted.delete(key); - changed = true; - } - } - } - } - - const sanitized: PermissionsStaffArgs[] = [...granted.values()]; + const sanitized: PermissionsStaffArgs[] = unrestricted + ? [] + : await sanitizeStaffGrants(c, { permissions, type }); const [updated] = await c .get("db") diff --git a/packages/vitnode/src/api/modules/admin/staff/staff.admin.module.ts b/packages/vitnode/src/api/modules/admin/staff/staff.admin.module.ts index 7b66659c25..e1320d356e 100644 --- a/packages/vitnode/src/api/modules/admin/staff/staff.admin.module.ts +++ b/packages/vitnode/src/api/modules/admin/staff/staff.admin.module.ts @@ -6,6 +6,7 @@ import { createStaffAdminRoute } from "./routes/create.route"; import { deleteStaffAdminRoute } from "./routes/delete.route"; import { listModeratorsStaffAdminRoute } from "./routes/moderators.route"; import { permissionCatalogStaffAdminRoute } from "./routes/permission-catalog.route"; +import { scopeResourcesStaffAdminRoute } from "./routes/scope-resources.route"; import { showPermissionsStaffAdminRoute } from "./routes/show-permissions.route"; import { updatePermissionsStaffAdminRoute } from "./routes/update-permissions.route"; @@ -16,6 +17,7 @@ export const staffAdminModule = buildModule({ listModeratorsStaffAdminRoute, listAdminsStaffAdminRoute, permissionCatalogStaffAdminRoute, + scopeResourcesStaffAdminRoute, showPermissionsStaffAdminRoute, updatePermissionsStaffAdminRoute, createStaffAdminRoute, diff --git a/packages/vitnode/src/api/modules/moderation/lib/candidates.ts b/packages/vitnode/src/api/modules/moderation/lib/candidates.ts new file mode 100644 index 0000000000..c676f88988 --- /dev/null +++ b/packages/vitnode/src/api/modules/moderation/lib/candidates.ts @@ -0,0 +1,60 @@ +import type { Context } from "hono"; + +import { eq, inArray, or } from "drizzle-orm"; + +import { core_moderators_permissions } from "@/database/moderators"; +import { core_roles } from "@/database/roles"; +import { core_users, core_users_secondary_roles } from "@/database/users"; + +const LIMIT = 100; + +export const listModeratorCandidates = async (c: Context) => { + const db = c.get("db"); + const [entries, rootRoles] = await Promise.all([ + db + .select({ + roleId: core_moderators_permissions.roleId, + userId: core_moderators_permissions.userId, + }) + .from(core_moderators_permissions), + db + .select({ id: core_roles.id }) + .from(core_roles) + .where(eq(core_roles.root, true)), + ]); + + const roleIds = [ + ...new Set([ + ...entries.map(entry => entry.roleId), + ...rootRoles.map(role => role.id), + ]), + ].filter((id): id is number => id !== null); + const userIds = entries + .map(entry => entry.userId) + .filter((id): id is number => id !== null); + + const secondary = roleIds.length + ? await db + .select({ userId: core_users_secondary_roles.userId }) + .from(core_users_secondary_roles) + .where(inArray(core_users_secondary_roles.roleId, roleIds)) + : []; + const ids = [...new Set([...userIds, ...secondary.map(row => row.userId)])]; + + const conditions = [ + ...(ids.length ? [inArray(core_users.id, ids)] : []), + ...(roleIds.length ? [inArray(core_users.roleId, roleIds)] : []), + ]; + if (conditions.length === 0) return []; + + return await db + .select({ + id: core_users.id, + name: core_users.name, + nameCode: core_users.nameCode, + roleId: core_users.roleId, + }) + .from(core_users) + .where(or(...conditions)) + .limit(LIMIT); +}; diff --git a/packages/vitnode/src/api/modules/moderation/lib/notes.ts b/packages/vitnode/src/api/modules/moderation/lib/notes.ts new file mode 100644 index 0000000000..1b5d353926 --- /dev/null +++ b/packages/vitnode/src/api/modules/moderation/lib/notes.ts @@ -0,0 +1,66 @@ +import type { Context } from "hono"; + +import { and, eq } from "drizzle-orm"; + +import type { ModeratorContext } from "@/api/lib/moderation/access"; + +import { canOnReport } from "@/api/lib/moderation/access"; +import { core_moderation_report_events } from "@/database/moderation"; +import { MODERATION_PERMISSIONS } from "@/lib/moderation/const"; + +import type { ReportRow } from "./reports"; + +import { reportError } from "./reports"; + +type NoteAction = "delete" | "edit"; + +const ANY_NOTE_PERMISSION: Record = { + delete: MODERATION_PERMISSIONS.deleteNotes, + edit: MODERATION_PERMISSIONS.editNotes, +}; + +export const canChangeNote = ( + c: Context, + moderator: ModeratorContext, + report: Pick, + note: { actorId: null | number }, + action: NoteAction, +): boolean => { + const ref = { + pluginId: report.pluginId, + scopeId: report.scopeId, + targetType: report.targetType, + }; + if ( + !canOnReport(c, moderator.set, ref, MODERATION_PERMISSIONS.manageReports) + ) { + return false; + } + + return ( + note.actorId === moderator.user.id || + canOnReport(c, moderator.set, ref, ANY_NOTE_PERMISSION[action]) + ); +}; + +export const loadReportNote = async ( + c: Context, + reportId: number, + noteId: number, +) => { + const [note] = await c + .get("db") + .select() + .from(core_moderation_report_events) + .where( + and( + eq(core_moderation_report_events.id, noteId), + eq(core_moderation_report_events.reportId, reportId), + eq(core_moderation_report_events.type, "note"), + ), + ) + .limit(1); + if (!note) throw reportError(404, { error: "note_not_found" }); + + return note; +}; diff --git a/packages/vitnode/src/api/modules/moderation/lib/people.ts b/packages/vitnode/src/api/modules/moderation/lib/people.ts new file mode 100644 index 0000000000..340ab682ca --- /dev/null +++ b/packages/vitnode/src/api/modules/moderation/lib/people.ts @@ -0,0 +1,57 @@ +import type { Context } from "hono"; + +import { eq, inArray } from "drizzle-orm"; + +import { storageUrlOf } from "@/api/lib/storage-url"; +import { core_files } from "@/database/files"; +import { core_roles } from "@/database/roles"; +import { core_users } from "@/database/users"; + +export interface ReportPerson { + avatarColor: string; + avatarUrl: null | string; + id: number; + name: string; + nameCode: string; + role: { color: null | string; prefix: null | string }; +} + +export const loadReportPeople = async ( + c: Context, + ids: readonly (null | number | undefined)[], +): Promise> => { + const unique = [ + ...new Set(ids.filter((id): id is number => typeof id === "number")), + ]; + if (unique.length === 0) return new Map(); + + const rows = await c + .get("db") + .select({ + avatarColor: core_users.avatarColor, + avatarKey: core_files.key, + id: core_users.id, + name: core_users.name, + nameCode: core_users.nameCode, + roleColor: core_roles.color, + rolePrefix: core_roles.prefix, + }) + .from(core_users) + .leftJoin(core_roles, eq(core_roles.id, core_users.roleId)) + .leftJoin(core_files, eq(core_files.id, core_users.avatarId)) + .where(inArray(core_users.id, unique)); + + return new Map( + rows.map(row => [ + row.id, + { + avatarColor: row.avatarColor, + avatarUrl: storageUrlOf(c, row.avatarKey), + id: row.id, + name: row.name, + nameCode: row.nameCode, + role: { color: row.roleColor ?? null, prefix: row.rolePrefix ?? null }, + }, + ]), + ); +}; diff --git a/packages/vitnode/src/api/modules/moderation/lib/reports.ts b/packages/vitnode/src/api/modules/moderation/lib/reports.ts new file mode 100644 index 0000000000..29715cb06a --- /dev/null +++ b/packages/vitnode/src/api/modules/moderation/lib/reports.ts @@ -0,0 +1,271 @@ +import type { Context } from "hono"; + +import { and, count, desc, eq, gte, inArray, isNull, or } from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; + +import type { ModeratorContext } from "@/api/lib/moderation/access"; +import type { RegisteredReportTarget } from "@/api/lib/moderation/targets"; +import type { ContentDatabase } from "@/content/server/service"; +import type { ModerationReportEventType } from "@/lib/moderation/const"; + +import { + canOnReport, + syncReportTargetScope, +} from "@/api/lib/moderation/access"; +import { findReportTarget } from "@/api/lib/moderation/registry"; +import { + core_moderation_report_events, + core_moderation_reports, +} from "@/database/moderation"; +import { MODERATION_PERMISSIONS } from "@/lib/moderation/const"; + +export type ReportRow = typeof core_moderation_reports.$inferSelect; + +export const reportOpenKey = ({ + pluginId, + reporterId, + targetId, + targetType, +}: { + pluginId: string; + reporterId: number; + targetId: string; + targetType: string; +}): string => `${reporterId}:${pluginId}:${targetType}:${targetId}`; + +const jsonError = ( + status: 400 | 403 | 404 | 409 | 429, + body: Record, +): HTTPException => + new HTTPException(status, { + message: String(body.error), + res: Response.json(body, { status }), + }); + +export const reportNotFound = (): HTTPException => + jsonError(404, { error: "not_found" }); + +export const reportConflict = ( + current: null | Pick, + error = "conflict", +): HTTPException => + jsonError(409, { + current: current + ? { + assigneeId: current.assigneeId, + status: current.status, + version: current.version, + } + : null, + error, + }); + +export const reportError = jsonError; + +export const loadReport = async ( + db: ContentDatabase, + id: number, +): Promise => { + const [row] = await db + .select() + .from(core_moderation_reports) + .where(eq(core_moderation_reports.id, id)) + .limit(1); + + return row ?? null; +}; + +export const updateReport = async ( + db: ContentDatabase, + report: Pick, + patch: Partial< + Omit + >, +): Promise => { + const [updated] = await db + .update(core_moderation_reports) + .set({ ...patch, version: report.version + 1 }) + .where( + and( + eq(core_moderation_reports.id, report.id), + eq(core_moderation_reports.version, report.version), + ), + ) + .returning(); + if (updated) return updated; + + throw reportConflict(await loadReport(db, report.id)); +}; + +export const appendReportEvent = async ( + db: ContentDatabase, + event: { + actorId: null | number; + data?: Record; + reportId: number; + type: ModerationReportEventType; + }, +): Promise => { + await db.insert(core_moderation_report_events).values({ + actorId: event.actorId, + data: event.data ?? {}, + reportId: event.reportId, + type: event.type, + }); +}; + +export interface AuthorizedReport { + report: ReportRow; + scopeId: null | string; + target: RegisteredReportTarget | undefined; +} + +export const authorizeReport = async ( + c: Context, + moderator: ModeratorContext, + id: number, + { manage = false }: { manage?: boolean } = {}, +): Promise => { + const db = c.get("db"); + let report = await loadReport(db, id); + if (!report) throw reportNotFound(); + + const target = findReportTarget(c.get("core").moderation, { + pluginId: report.pluginId, + type: report.targetType, + }); + + if (target?.definition.scope) { + const current = await syncReportTargetScope(c, { + target, + targetId: report.targetId, + }); + if (current !== undefined && current !== report.scopeId) { + report = (await loadReport(db, id)) ?? report; + } + } + + const ref = { + pluginId: report.pluginId, + scopeId: report.scopeId, + targetType: report.targetType, + }; + if (!canOnReport(c, moderator.set, ref, MODERATION_PERMISSIONS.viewReports)) { + throw reportNotFound(); + } + if ( + manage && + !canOnReport(c, moderator.set, ref, MODERATION_PERMISSIONS.manageReports) + ) { + throw reportError(403, { error: "forbidden" }); + } + + return { report, scopeId: report.scopeId, target }; +}; + +export const countRecentReports = async ( + c: Context, + reporterId: number, +): Promise => { + const [row] = await c + .get("db") + .select({ value: count() }) + .from(core_moderation_reports) + .where( + and( + eq(core_moderation_reports.reporterId, reporterId), + gte( + core_moderation_reports.createdAt, + new Date(Date.now() - 60 * 60 * 1000), + ), + ), + ); + + return row?.value ?? 0; +}; + +export const relatedReports = async ( + c: Context, + moderator: ModeratorContext, + report: ReportRow, +) => { + const rows = await c + .get("db") + .select({ + createdAt: core_moderation_reports.createdAt, + description: core_moderation_reports.description, + id: core_moderation_reports.id, + reason: core_moderation_reports.reason, + reporterId: core_moderation_reports.reporterId, + scopeId: core_moderation_reports.scopeId, + status: core_moderation_reports.status, + }) + .from(core_moderation_reports) + .where( + and( + eq(core_moderation_reports.pluginId, report.pluginId), + eq(core_moderation_reports.targetType, report.targetType), + eq(core_moderation_reports.targetId, report.targetId), + ), + ) + .orderBy(desc(core_moderation_reports.createdAt)) + .limit(50); + + return rows + .filter(row => row.id !== report.id) + .filter(row => + canOnReport( + c, + moderator.set, + { + pluginId: report.pluginId, + scopeId: row.scopeId, + targetType: report.targetType, + }, + MODERATION_PERMISSIONS.viewReports, + ), + ) + .map(({ scopeId: _scope, ...row }) => row); +}; + +export const queueFilters = ({ + assigned, + status, + userId, +}: { + assigned: "any" | "me" | "unassigned"; + status: "all" | "done" | "in_progress" | "new" | "open"; + userId: number; +}) => { + const table = core_moderation_reports; + const byStatus = + status === "all" + ? undefined + : status === "open" + ? or(eq(table.status, "new"), eq(table.status, "in_progress")) + : eq(table.status, status); + const byAssignee = + assigned === "me" + ? eq(table.assigneeId, userId) + : assigned === "unassigned" + ? isNull(table.assigneeId) + : undefined; + + return [byStatus, byAssignee].filter(condition => condition !== undefined); +}; + +export const reportTargetTriplets = ( + rows: Pick[], +) => + or( + ...rows.map(row => + and( + eq(core_moderation_reports.pluginId, row.pluginId), + eq(core_moderation_reports.targetType, row.targetType), + eq(core_moderation_reports.targetId, row.targetId), + ), + ), + ); + +export const reportIdsOf = (ids: number[]) => + inArray(core_moderation_reports.id, ids); diff --git a/packages/vitnode/src/api/modules/moderation/lib/schema.ts b/packages/vitnode/src/api/modules/moderation/lib/schema.ts new file mode 100644 index 0000000000..0975bc95c5 --- /dev/null +++ b/packages/vitnode/src/api/modules/moderation/lib/schema.ts @@ -0,0 +1,82 @@ +import { z } from "@hono/zod-openapi"; + +import { createRichTextDocumentSchema } from "@/content/rich-text/document"; +import { + MODERATION_LIMITS, + MODERATION_REPORT_EVENT_TYPES, + MODERATION_REPORT_REASONS, + MODERATION_REPORT_RESOLUTIONS, + MODERATION_REPORT_STATUSES, +} from "@/lib/moderation/const"; + +export const reportIdParams = z.object({ + id: z.coerce.number().int().positive().openapi({ example: 1 }), +}); + +export const expectedVersionBody = z.object({ + expectedVersion: z.number().int().positive(), +}); + +export const errorSchema = z.object({ error: z.string() }); + +export const conflictSchema = z.object({ + error: z.string(), + current: z + .object({ + assigneeId: z.number().nullable(), + status: z.enum(MODERATION_REPORT_STATUSES), + version: z.number(), + }) + .nullable(), +}); + +export const reportUserSchema = z.object({ + id: z.number(), + name: z.string(), + nameCode: z.string(), +}); + +export const reportPersonSchema = reportUserSchema.extend({ + avatarColor: z.string(), + avatarUrl: z.string().nullable(), + role: z.object({ + color: z.string().nullable(), + prefix: z.string().nullable(), + }), +}); + +export const reportStateSchema = z.object({ + assigneeId: z.number().nullable(), + status: z.enum(MODERATION_REPORT_STATUSES), + version: z.number(), +}); + +export const reasonSchema = z.enum(MODERATION_REPORT_REASONS); +export const resolutionSchema = z.enum(MODERATION_REPORT_RESOLUTIONS); +export const eventTypeSchema = z.enum(MODERATION_REPORT_EVENT_TYPES); + +export const noteSchema = z + .string() + .trim() + .min(MODERATION_LIMITS.explanationMinLength) + .max(MODERATION_LIMITS.noteMaxLength); + +export const richNoteSchema = z.union([ + noteSchema, + createRichTextDocumentSchema({ + maxBytes: MODERATION_LIMITS.richNoteMaxBytes, + required: true, + }), +]); + +export const optionalExplanationSchema = z + .string() + .trim() + .max(MODERATION_LIMITS.noteMaxLength) + .nullish() + .transform(value => (value === "" ? null : (value ?? null))); + +export const json = (schema: T, description: string) => ({ + content: { "application/json": { schema } }, + description, +}); diff --git a/packages/vitnode/src/api/modules/moderation/moderation.module.ts b/packages/vitnode/src/api/modules/moderation/moderation.module.ts new file mode 100644 index 0000000000..930fe3d0ee --- /dev/null +++ b/packages/vitnode/src/api/modules/moderation/moderation.module.ts @@ -0,0 +1,38 @@ +import { buildModule } from "@/api/lib/module"; +import { CONFIG_PLUGIN } from "@/config"; + +import { createReportRoute } from "./routes/create.route"; +import { listReportsRoute } from "./routes/list.route"; +import { + deleteReportNoteRoute, + editReportNoteRoute, +} from "./routes/notes.routes"; +import { showReportRoute } from "./routes/show.route"; +import { + assignReportRoute, + claimReportRoute, + noteReportRoute, + reopenReportRoute, + reportActionRoute, + reportAssigneesRoute, + resolveReportRoute, +} from "./routes/workflow.routes"; + +export const moderationModule = buildModule({ + pluginId: CONFIG_PLUGIN.pluginId, + name: "moderation", + routes: [ + createReportRoute, + listReportsRoute, + showReportRoute, + claimReportRoute, + assignReportRoute, + noteReportRoute, + editReportNoteRoute, + deleteReportNoteRoute, + resolveReportRoute, + reopenReportRoute, + reportActionRoute, + reportAssigneesRoute, + ], +}); diff --git a/packages/vitnode/src/api/modules/moderation/reports.test.ts b/packages/vitnode/src/api/modules/moderation/reports.test.ts new file mode 100644 index 0000000000..fe564912f5 --- /dev/null +++ b/packages/vitnode/src/api/modules/moderation/reports.test.ts @@ -0,0 +1,586 @@ +import type { Context } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { describe, expect, it, vi } from "vitest"; + +import type { PermissionsStaffArgs } from "@/api/lib/permission-staff"; +import type { ModerationCaptureResult } from "@/api/moderation"; + +import { defineModerationScope, defineReportTarget } from "@/api/moderation"; +import { core_files } from "@/database/files"; +import { + core_moderation_report_events, + core_moderation_reports, +} from "@/database/moderation"; +import { core_roles } from "@/database/roles"; +import { core_users } from "@/database/users"; +import { createTestCache } from "@/tests/cache"; +import { createMemoryDb } from "@/tests/memory-db"; +import { + grantStaffPermissions, + ROOT_STAFF_PERMISSIONS, +} from "@/tests/staff-permissions"; + +import { moderationModule } from "./moderation.module"; + +const FORUM = "@vitnode/forum"; +const FAMILY = "forum.section"; +const TYPE = "forum.topic"; +const SECTION_A = "10"; +const SECTION_B = "20"; +const DRAFT_TOPIC = "99"; + +const MEMBER_ROLE = 3; +const REPORTER = 100; +const SECOND_REPORTER = 101; +const MOD_A = 200; +const SECOND_MOD_A = 201; +const MOD_B = 202; +const ROOT_MOD = 300; + +const topics = (permission: string, section: string): PermissionsStaffArgs => ({ + module: "topics", + permission, + plugin: FORUM, + scope: { family: FAMILY, ids: [section], mode: "selected" }, +}); + +const moderatorOf = (section: string) => [ + topics("can_view_reports", section), + topics("can_manage_reports", section), + topics("can_view_hidden", section), + topics("can_hide", section), +]; + +const harness = async () => { + const sections = new Map([ + ["1", SECTION_A], + ["2", SECTION_A], + ["3", SECTION_B], + ]); + const deleted = new Set(); + const hidden = new Set(); + const fixture = { + body: "Original body", + failHide: false, + onCapture: undefined as (() => Promise) | undefined, + servedLocale: "en", + }; + const effects = vi.fn(async () => Promise.resolve()); + + const target = defineReportTarget({ + actions: { + hide: { + available: state => !state.hidden, + permission: "can_hide", + run: async (_c, { targetId }) => { + if (fixture.failHide) throw new Error("hide failed"); + hidden.add(targetId); + + return Promise.resolve({ changed: true, effects }); + }, + }, + }, + capture: async ( + _c, + { targetId }, + ): Promise => { + await fixture.onCapture?.(); + if (targetId === DRAFT_TOPIC || deleted.has(targetId)) return null; + + return { + authors: [{ id: 1, name: "Author" }], + fields: [ + { kind: "text", label: null, name: "body", value: fixture.body }, + ], + locale: { + fallback: false, + requested: null, + served: fixture.servedLocale, + }, + scope: null, + source: { publishedAt: null, updatedAt: null, version: null }, + title: `Topic ${targetId}`, + url: `/topics/${targetId}`, + }; + }, + current: async (_c, { targetId }) => + Promise.resolve( + deleted.has(targetId) + ? null + : { hidden: hidden.has(targetId), url: `/topics/${targetId}` }, + ), + label: "Topic", + parseId: value => (/^\d+$/.test(value) ? value : null), + permissionModule: "topics", + resolveScope: async (_c, targetId) => + Promise.resolve( + deleted.has(targetId) ? undefined : sections.get(targetId), + ), + scope: FAMILY, + type: TYPE, + }); + const scope = defineModerationScope({ + find: async () => Promise.resolve([]), + id: FAMILY, + label: "Section", + list: async () => Promise.resolve({ items: [], nextCursor: null }), + }); + + const cache = createTestCache(); + for (const [userId, permissions] of [ + [MOD_A, moderatorOf(SECTION_A)], + [SECOND_MOD_A, moderatorOf(SECTION_A)], + [MOD_B, moderatorOf(SECTION_B)], + [ROOT_MOD, ROOT_STAFF_PERMISSIONS], + ] as const) { + await grantStaffPermissions(cache, { + permissions, + type: "moderator", + userId, + }); + } + + const users = [ + REPORTER, + SECOND_REPORTER, + MOD_A, + SECOND_MOD_A, + MOD_B, + ROOT_MOD, + ]; + const memory = createMemoryDb([ + [ + core_users, + users.map(id => ({ + email: `${id}@example.com`, + id, + name: `User ${id}`, + nameCode: `user-${id}`, + roleId: MEMBER_ROLE, + })), + ], + [core_roles, [{ color: null, id: MEMBER_ROLE, prefix: null }]], + [core_files, []], + [core_moderation_reports, []], + [core_moderation_report_events, []], + ]); + const emit = vi.fn(async () => Promise.resolve()); + + const app = new OpenAPIHono(); + app.use("*", async (c, next) => { + const userId = Number(c.req.header("x-user")); + c.set( + "user", + (users.includes(userId) + ? { id: userId, roleId: MEMBER_ROLE } + : null) as unknown as Context["var"]["user"], + ); + c.set("cache", cache); + c.set("db", memory.db as unknown as Context["var"]["db"]); + c.set("events", { emit } as unknown as Context["var"]["events"]); + c.set("core", { + moderation: { + scopes: [{ definition: scope, pluginId: FORUM }], + targets: [{ definition: target, pluginId: FORUM }], + }, + permissionStaff: [], + } as unknown as Context["var"]["core"]); + await next(); + }); + app.route("/", moderationModule.hono); + + const call = async ( + userId: number, + method: "GET" | "POST", + path: string, + body?: Record, + ) => + await app.request(path, { + method, + headers: { + "content-type": "application/json", + "x-user": String(userId), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + + const report = async ( + userId: number, + targetId: string, + extra: Record = {}, + ) => + await call(userId, "POST", "/reports", { + pluginId: FORUM, + reason: "spam", + targetId, + targetType: TYPE, + ...extra, + }); + + const reportId = async (userId: number, targetId: string) => { + const res = await report(userId, targetId); + expect(res.status).toBe(201); + + return ((await res.json()) as { id: number }).id; + }; + + const row = (id: number) => + memory.rows(core_moderation_reports).find(entry => entry.id === id); + const eventsOf = (id: number) => + memory + .rows(core_moderation_report_events) + .filter(entry => entry.reportId === id) + .map(entry => ({ data: entry.data, type: entry.type })); + + const seed = async (targetId: string, scopeId: string, createdAt: Date) => { + await memory.db.insert(core_moderation_reports).values({ + createdAt, + openKey: null, + pluginId: FORUM, + reason: "spam", + reporterId: null, + scopeFamily: FAMILY, + scopeId, + snapshot: {}, + targetId, + targetType: TYPE, + }); + }; + + return { + call, + deleted, + effects, + emit, + eventsOf, + fixture, + memory, + report, + reportId, + row, + sections, + seed, + }; +}; + +interface Detail { + current: { exists: boolean; hidden: boolean }; + related: { id: number }[]; + report: { scope: null | { id: string }; status: string; version: number }; + snapshot: { + fields: { value: unknown }[]; + locale: { + fallback: boolean; + requested: null | string; + served: null | string; + }; + title: string; + }; +} + +const detail = async ( + h: Awaited>, + userId: number, + id: number, +) => { + const res = await h.call(userId, "GET", `/reports/${id}`); + + return { + body: res.status === 200 ? ((await res.json()) as Detail) : null, + status: res.status, + }; +}; + +const queue = async ( + h: Awaited>, + userId: number, +) => { + const res = await h.call(userId, "GET", "/reports?status=all"); + expect(res.status).toBe(200); + + return (await res.json()) as { + items: { id: number; relatedCount: number }[]; + total: number; + }; +}; + +describe("filing a report", () => { + it("refuses a second open report from the same reporter, not from another", async () => { + const h = await harness(); + const first = await h.reportId(REPORTER, "1"); + + const again = await h.report(REPORTER, "1"); + expect(again.status).toBe(409); + expect(await again.json()).toEqual({ error: "duplicate" }); + + const other = await h.reportId(SECOND_REPORTER, "1"); + expect(other).not.toBe(first); + }); + + it("turns a duplicate that raced past the check into a 409", async () => { + const h = await harness(); + h.fixture.onCapture = async () => { + h.fixture.onCapture = undefined; + await h.seed("1", SECTION_A, new Date()); + await h.memory.db + .update(core_moderation_reports) + .set({ openKey: `${REPORTER}:${FORUM}:${TYPE}:1` }); + }; + + const res = await h.report(REPORTER, "1"); + + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ error: "duplicate" }); + expect(h.memory.rows(core_moderation_reports)).toHaveLength(1); + expect(h.memory.rows(core_moderation_report_events)).toEqual([]); + }); + + it("answers 404 when the reporter cannot read the target", async () => { + const h = await harness(); + + const res = await h.report(REPORTER, DRAFT_TOPIC); + + expect(res.status).toBe(404); + expect(h.memory.rows(core_moderation_reports)).toEqual([]); + }); + + it("records that the snapshot was served in a fallback language", async () => { + const h = await harness(); + const plain = await h.reportId(REPORTER, "1"); + const localized = await h.report(SECOND_REPORTER, "1", { + locale: "pl", + requestedLocale: "pl", + }); + expect(localized.status).toBe(201); + const { id } = (await localized.json()) as { id: number }; + + expect((await detail(h, ROOT_MOD, id)).body?.snapshot.locale).toEqual({ + fallback: true, + requested: "pl", + served: "en", + }); + expect( + (await detail(h, ROOT_MOD, plain)).body?.snapshot.locale.fallback, + ).toBe(false); + }); +}); + +describe("the report queue", () => { + it("filters by the moderator's sections before counting and paginating", async () => { + const h = await harness(); + const day = (n: number) => new Date(Date.UTC(2026, 0, n)); + await h.seed("1", SECTION_A, day(1)); + await h.seed("2", SECTION_A, day(2)); + for (let n = 0; n < 30; n++) await h.seed("3", SECTION_B, day(3 + n)); + + const scoped = await queue(h, MOD_A); + expect(scoped.total).toBe(2); + expect(scoped.items).toHaveLength(2); + + const other = await queue(h, MOD_B); + expect(other.total).toBe(30); + expect(other.items).toHaveLength(25); + + expect((await queue(h, ROOT_MOD)).total).toBe(32); + }); + + it("counts related reports only among those the moderator may view", async () => { + const h = await harness(); + await h.reportId(REPORTER, "1"); + await h.reportId(SECOND_REPORTER, "1"); + + const { items } = await queue(h, MOD_A); + + expect(items.map(item => item.relatedCount)).toEqual([1, 1]); + expect((await queue(h, MOD_B)).items).toEqual([]); + }); +}); + +describe("a report's detail", () => { + it("is a 404 outside the moderator's sections, and lists related reports inside", async () => { + const h = await harness(); + const first = await h.reportId(REPORTER, "1"); + const second = await h.reportId(SECOND_REPORTER, "1"); + await h.reportId(REPORTER, "2"); + + expect((await detail(h, MOD_B, first)).status).toBe(404); + + const { body } = await detail(h, MOD_A, first); + expect(body?.related.map(entry => entry.id)).toEqual([second]); + }); + + it("keeps the stored snapshot after the target is edited and deleted", async () => { + const h = await harness(); + const id = await h.reportId(REPORTER, "1"); + const before = (await detail(h, MOD_A, id)).body?.snapshot; + expect(before?.fields).toEqual([ + expect.objectContaining({ value: "Original body" }), + ]); + + h.fixture.body = "Edited body"; + expect((await detail(h, MOD_A, id)).body?.snapshot).toEqual(before); + + h.deleted.add("1"); + const gone = await detail(h, MOD_A, id); + expect(gone.body?.snapshot).toEqual(before); + expect(gone.body?.current.exists).toBe(false); + expect(gone.body?.report.scope).toEqual({ family: FAMILY, id: SECTION_A }); + }); + + it("follows a topic moved to another section, unassigning who lost access", async () => { + const h = await harness(); + const id = await h.reportId(REPORTER, "2"); + const claim = await h.call(MOD_A, "POST", `/reports/${id}/claim`, { + expectedVersion: 1, + }); + expect(claim.status).toBe(200); + + h.sections.set("2", SECTION_B); + + expect((await detail(h, MOD_A, id)).status).toBe(404); + expect(h.row(id)).toEqual( + expect.objectContaining({ + assigneeId: null, + scopeId: SECTION_B, + status: "new", + }), + ); + expect(h.eventsOf(id)).toContainEqual({ + data: { previousAssigneeId: MOD_A, reason: "lost_access" }, + type: "unassigned", + }); + expect((await detail(h, MOD_B, id)).status).toBe(200); + }); +}); + +describe("working a report", () => { + it("lets one claim win and tells the other why it lost", async () => { + const h = await harness(); + const id = await h.reportId(REPORTER, "1"); + + const first = await h.call(MOD_A, "POST", `/reports/${id}/claim`, { + expectedVersion: 1, + }); + expect(await first.json()).toEqual({ + assigneeId: MOD_A, + status: "in_progress", + version: 2, + }); + + const stale = await h.call(SECOND_MOD_A, "POST", `/reports/${id}/claim`, { + expectedVersion: 1, + }); + expect(stale.status).toBe(409); + expect(await stale.json()).toEqual({ + current: { assigneeId: MOD_A, status: "in_progress", version: 2 }, + error: "conflict", + }); + + const taken = await h.call(SECOND_MOD_A, "POST", `/reports/${id}/claim`, { + expectedVersion: 2, + }); + expect(taken.status).toBe(409); + expect(await taken.json()).toEqual( + expect.objectContaining({ error: "already_claimed" }), + ); + }); + + it("resolves with an outcome and reopens with the history kept", async () => { + const h = await harness(); + const id = await h.reportId(REPORTER, "1"); + const resolve = async (body: Record) => + await h.call(MOD_A, "POST", `/reports/${id}/resolve`, { + expectedVersion: 1, + resolution: "no_violation", + ...body, + }); + + expect((await resolve({ resolution: "unknown" })).status).toBe(400); + + const resolved = await resolve({ explanation: "Not spam." }); + expect(await resolved.json()).toEqual({ + assigneeId: null, + status: "done", + version: 2, + }); + expect(h.row(id)).toEqual( + expect.objectContaining({ openKey: null, resolution: "no_violation" }), + ); + + const reopened = await h.call(MOD_A, "POST", `/reports/${id}/reopen`, { + expectedVersion: 2, + reason: "Missed context.", + }); + expect(await reopened.json()).toEqual({ + assigneeId: null, + status: "new", + version: 3, + }); + expect(h.row(id)).toEqual( + expect.objectContaining({ + openKey: `${REPORTER}:${FORUM}:${TYPE}:1`, + resolution: null, + }), + ); + expect(h.eventsOf(id).map(event => event.type)).toEqual([ + "created", + "resolved", + "reopened", + ]); + expect(h.eventsOf(id)[2]?.data).toEqual({ + previousResolution: "no_violation", + reason: "Missed context.", + }); + }); + + it("records a successful action and runs its effects after the commit", async () => { + const h = await harness(); + const id = await h.reportId(REPORTER, "1"); + const order: string[] = []; + const transaction = h.memory.db.transaction.bind(h.memory.db); + vi.spyOn(h.memory.db, "transaction").mockImplementation(async run => { + const result = await transaction(run); + order.push("committed"); + + return result; + }); + h.effects.mockImplementation(async () => { + order.push("effects"); + + return Promise.resolve(); + }); + + const res = await h.call(MOD_A, "POST", `/reports/${id}/actions/hide`, { + expectedVersion: 1, + }); + + expect(await res.json()).toEqual({ + assigneeId: null, + changed: true, + status: "new", + version: 2, + }); + expect(order).toEqual(["committed", "effects"]); + expect(h.eventsOf(id)).toContainEqual({ + data: { action: "hide" }, + type: "action", + }); + expect((await detail(h, MOD_A, id)).body?.current.hidden).toBe(true); + }); + + it("leaves no trace of an action that fails", async () => { + const h = await harness(); + const id = await h.reportId(REPORTER, "1"); + h.fixture.failHide = true; + + const res = await h.call(MOD_A, "POST", `/reports/${id}/actions/hide`, { + expectedVersion: 1, + }); + + expect(res.status).toBe(500); + expect(h.eventsOf(id).map(event => event.type)).toEqual(["created"]); + expect(h.row(id)?.version).toBe(1); + expect(h.effects).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/vitnode/src/api/modules/moderation/routes/create.route.ts b/packages/vitnode/src/api/modules/moderation/routes/create.route.ts new file mode 100644 index 0000000000..ac7771f92c --- /dev/null +++ b/packages/vitnode/src/api/modules/moderation/routes/create.route.ts @@ -0,0 +1,192 @@ +import { z } from "@hono/zod-openapi"; +import { and, eq } from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; + +import { isUniqueViolation } from "@/api/lib/db-errors"; +import { findReportTarget } from "@/api/lib/moderation/registry"; +import { sealReportSnapshot } from "@/api/lib/moderation/snapshot"; +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_moderation_reports } from "@/database/moderation"; +import { MODERATION_LIMITS } from "@/lib/moderation/const"; + +import { + appendReportEvent, + countRecentReports, + reportError, + reportOpenKey, +} from "../lib/reports"; +import { errorSchema, json, reasonSchema } from "../lib/schema"; + +const localeSchema = z + .string() + .max(20) + .regex(/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/); + +const createBody = z + .object({ + pluginId: z.string().min(1).max(100), + targetType: z.string().min(1).max(100), + targetId: z.string().min(1).max(64), + locale: localeSchema.nullable().optional(), + requestedLocale: localeSchema.nullable().optional(), + reason: reasonSchema, + description: z + .string() + .trim() + .max(MODERATION_LIMITS.descriptionMaxLength) + .nullable() + .optional(), + }) + .refine( + body => + body.reason !== "other" || + (body.description?.length ?? 0) >= + MODERATION_LIMITS.otherDescriptionMinLength, + { + message: `Describe the problem in at least ${MODERATION_LIMITS.otherDescriptionMinLength} characters when the reason is "other".`, + path: ["description"], + }, + ); + +export const createReportRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "post", + path: "/reports", + description: + "Report content to the moderators. The server captures the snapshot - the client sends only the target, locale, reason and description", + request: { + body: { content: { "application/json": { schema: createBody } } }, + }, + responses: { + 201: json(z.object({ id: z.number() }), "Report filed"), + 400: { description: "Invalid reason or description" }, + 401: { description: "Sign in first" }, + 404: json( + errorSchema, + "The target does not exist, or the reporter cannot read it", + ), + 409: json( + errorSchema, + "The reporter already has an open report about this target", + ), + 429: json(errorSchema, "Too many reports"), + }, + }, + handler: async c => { + const user = c.get("user"); + if (!user) throw new HTTPException(401, { message: "Sign in first." }); + + const body = c.req.valid("json"); + const registered = findReportTarget(c.get("core").moderation, { + pluginId: body.pluginId, + type: body.targetType, + }); + if (!registered) throw reportError(404, { error: "not_found" }); + const { definition, pluginId } = registered; + + const targetId = definition.parseId(body.targetId); + if (targetId === null) throw reportError(404, { error: "not_found" }); + + if ( + (await countRecentReports(c, user.id)) >= + MODERATION_LIMITS.reportsPerUserPerHour + ) { + c.header("Retry-After", "3600"); + throw reportError(429, { error: "rate_limited" }); + } + + const openKey = reportOpenKey({ + pluginId, + reporterId: user.id, + targetId, + targetType: definition.type, + }); + const [existing] = await c + .get("db") + .select({ id: core_moderation_reports.id }) + .from(core_moderation_reports) + .where(and(eq(core_moderation_reports.openKey, openKey))) + .limit(1); + if (existing) throw reportError(409, { error: "duplicate" }); + + const capture = await definition.capture(c, { + locale: body.locale ?? null, + targetId, + }); + if (!capture) throw reportError(404, { error: "not_found" }); + + const scopeId = definition.scope + ? ((await definition.resolveScope?.(c, targetId)) ?? null) + : null; + const requested = body.requestedLocale ?? capture.locale.requested; + const snapshot = sealReportSnapshot({ + capture: { + ...capture, + locale: { + fallback: + requested !== null && + capture.locale.served !== null && + requested.toLowerCase() !== capture.locale.served.toLowerCase(), + requested, + served: capture.locale.served, + }, + scope: + definition.scope && scopeId + ? { family: definition.scope, id: scopeId } + : capture.scope, + }, + target: { id: targetId, pluginId, type: definition.type }, + }); + + let reportId: number; + try { + reportId = await c.get("db").transaction(async tx => { + const [created] = await tx + .insert(core_moderation_reports) + .values({ + description: + body.description?.trim() === "" + ? null + : (body.description ?? null), + openKey, + pluginId, + reason: body.reason, + reporterId: user.id, + scopeFamily: definition.scope ?? null, + scopeId, + snapshot, + targetId, + targetTitle: snapshot.title, + targetType: definition.type, + }) + .returning({ id: core_moderation_reports.id }); + + await appendReportEvent(tx, { + actorId: user.id, + data: { reason: body.reason }, + reportId: created.id, + type: "created", + }); + + return created.id; + }); + } catch (error) { + if (isUniqueViolation(error)) { + throw reportError(409, { error: "duplicate" }); + } + throw error; + } + + await c.get("events").emit("moderation.report.created", { + pluginId, + reason: body.reason, + reportId, + targetId, + targetType: definition.type, + }); + + return c.json({ id: reportId }, 201); + }, +}); diff --git a/packages/vitnode/src/api/modules/moderation/routes/list.route.ts b/packages/vitnode/src/api/modules/moderation/routes/list.route.ts new file mode 100644 index 0000000000..81e316c975 --- /dev/null +++ b/packages/vitnode/src/api/modules/moderation/routes/list.route.ts @@ -0,0 +1,188 @@ +import { z } from "@hono/zod-openapi"; +import { and, count, desc, eq, ilike, or } from "drizzle-orm"; + +import { + requireModerator, + viewableReportsCondition, +} from "@/api/lib/moderation/access"; +import { findReportTarget } from "@/api/lib/moderation/registry"; +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_moderation_reports } from "@/database/moderation"; +import { containsLikePattern } from "@/lib/api/like-pattern"; +import { + MODERATION_LIMITS, + MODERATION_REPORT_STATUSES, +} from "@/lib/moderation/const"; + +import { loadReportPeople } from "../lib/people"; +import { queueFilters, reportTargetTriplets } from "../lib/reports"; +import { json, reasonSchema, reportPersonSchema } from "../lib/schema"; + +const itemSchema = z.object({ + id: z.number(), + pluginId: z.string(), + targetType: z.string(), + targetId: z.string(), + targetLabel: z.string().nullable(), + title: z.string().nullable(), + reason: reasonSchema, + status: z.enum(MODERATION_REPORT_STATUSES), + createdAt: z.date(), + version: z.number(), + assignee: reportPersonSchema.nullable(), + relatedCount: z.number(), +}); + +const searchedReportId = (search: string): number | undefined => { + const digits = search.replace(/^#/, ""); + const id = Number(digits); + + return /^[1-9]\d{0,9}$/.test(digits) && id <= 2_147_483_647 ? id : undefined; +}; + +const searchCondition = (search: string) => { + const id = searchedReportId(search); + + return or( + ilike(core_moderation_reports.targetTitle, containsLikePattern(search)), + id === undefined ? undefined : eq(core_moderation_reports.id, id), + ); +}; + +export const listReportsRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "get", + path: "/reports", + description: + "The ModCP report queue - only reports the moderator may view, filtered before counting and paginating", + request: { + query: z.object({ + status: z + .enum(["open", "new", "in_progress", "done", "all"]) + .default("open"), + assigned: z.enum(["any", "me", "unassigned"]).default("any"), + page: z.coerce.number().int().positive().max(10_000).default(1), + pageSize: z.coerce + .number() + .int() + .positive() + .max(100) + .default(MODERATION_LIMITS.queuePageSize), + search: z.string().trim().max(200).optional(), + }), + }, + responses: { + 200: json( + z.object({ + items: z.array(itemSchema), + page: z.number(), + pageSize: z.number(), + total: z.number(), + }), + "One page of reports", + ), + 401: { description: "Sign in first" }, + 403: { description: "Not a moderator" }, + }, + }, + handler: async c => { + const moderator = await requireModerator(c); + const { assigned, page, pageSize, search, status } = c.req.valid("query"); + const table = core_moderation_reports; + + const where = and( + viewableReportsCondition(c, moderator.set), + ...queueFilters({ assigned, status, userId: moderator.user.id }), + search ? searchCondition(search) : undefined, + ); + + const [[totalRow], rows] = await Promise.all([ + c.get("db").select({ value: count() }).from(table).where(where), + c + .get("db") + .select({ + assigneeId: table.assigneeId, + createdAt: table.createdAt, + id: table.id, + pluginId: table.pluginId, + reason: table.reason, + targetTitle: table.targetTitle, + status: table.status, + targetId: table.targetId, + targetType: table.targetType, + version: table.version, + }) + .from(table) + .where(where) + .orderBy(desc(table.createdAt), desc(table.id)) + .limit(pageSize) + .offset((page - 1) * pageSize), + ]); + + const related = rows.length + ? await c + .get("db") + .select({ + pluginId: table.pluginId, + targetId: table.targetId, + targetType: table.targetType, + value: count(), + }) + .from(table) + .where( + and( + viewableReportsCondition(c, moderator.set), + reportTargetTriplets(rows), + ), + ) + .groupBy(table.pluginId, table.targetType, table.targetId) + : []; + + const registry = c.get("core").moderation; + const people = await loadReportPeople( + c, + rows.map(row => row.assigneeId), + ); + + return c.json( + { + items: rows.map(row => { + const group = related.find( + entry => + entry.pluginId === row.pluginId && + entry.targetType === row.targetType && + entry.targetId === row.targetId, + ); + + return { + id: row.id, + pluginId: row.pluginId, + targetType: row.targetType, + targetId: row.targetId, + targetLabel: + findReportTarget(registry, { + pluginId: row.pluginId, + type: row.targetType, + })?.definition.label ?? null, + title: row.targetTitle, + reason: row.reason, + status: row.status, + createdAt: row.createdAt, + version: row.version, + assignee: + row.assigneeId === null + ? null + : (people.get(row.assigneeId) ?? null), + relatedCount: Math.max(0, (group?.value ?? 1) - 1), + }; + }), + page, + pageSize, + total: totalRow?.value ?? 0, + }, + 200, + ); + }, +}); diff --git a/packages/vitnode/src/api/modules/moderation/routes/notes.routes.ts b/packages/vitnode/src/api/modules/moderation/routes/notes.routes.ts new file mode 100644 index 0000000000..3343520fa1 --- /dev/null +++ b/packages/vitnode/src/api/modules/moderation/routes/notes.routes.ts @@ -0,0 +1,134 @@ +import { z } from "@hono/zod-openapi"; +import { eq } from "drizzle-orm"; + +import { requireModerator } from "@/api/lib/moderation/access"; +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_moderation_report_events } from "@/database/moderation"; + +import { canChangeNote, loadReportNote } from "../lib/notes"; +import { + appendReportEvent, + authorizeReport, + reportError, +} from "../lib/reports"; +import { + errorSchema, + json, + reportIdParams, + richNoteSchema, +} from "../lib/schema"; + +const noteParams = reportIdParams.extend({ + noteId: z.coerce.number().int().positive().openapi({ example: 1 }), +}); + +const noteResponses = { + 200: json(z.object({ ok: z.boolean() }), "Done"), + 400: { description: "Invalid input" }, + 401: { description: "Sign in first" }, + 403: json( + errorSchema, + "Not the note's author, and no permission for others' notes", + ), + 404: json( + errorSchema, + "No such report or note, or not one this moderator may see", + ), +}; + +export const editReportNoteRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "post", + path: "/reports/{id}/notes/{noteId}/edit", + description: + "Change an internal note. Authors edit their own notes; can_edit_notes edits anyone's", + request: { + params: noteParams, + body: { + content: { + "application/json": { schema: z.object({ body: richNoteSchema }) }, + }, + }, + }, + responses: noteResponses, + }, + handler: async c => { + const moderator = await requireModerator(c); + const { id, noteId } = c.req.valid("param"); + const { body } = c.req.valid("json"); + const { report } = await authorizeReport(c, moderator, id); + const note = await loadReportNote(c, report.id, noteId); + if (!canChangeNote(c, moderator, report, note, "edit")) { + throw reportError(403, { error: "forbidden" }); + } + + await c.get("db").transaction(async tx => { + await tx + .update(core_moderation_report_events) + .set({ + data: { + ...note.data, + body, + editedAt: new Date().toISOString(), + editedById: moderator.user.id, + }, + }) + .where(eq(core_moderation_report_events.id, note.id)); + await appendReportEvent(tx, { + actorId: moderator.user.id, + data: { after: body, before: note.data.body ?? null, noteId: note.id }, + reportId: report.id, + type: "note_edited", + }); + }); + + await c.get("events").emit("moderation.report.note_updated", { + noteId: note.id, + reportId: report.id, + }); + + return c.json({ ok: true }, 200); + }, +}); + +export const deleteReportNoteRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "post", + path: "/reports/{id}/notes/{noteId}/delete", + description: + "Remove an internal note. Authors delete their own notes; can_delete_notes deletes anyone's", + request: { params: noteParams }, + responses: noteResponses, + }, + handler: async c => { + const moderator = await requireModerator(c); + const { id, noteId } = c.req.valid("param"); + const { report } = await authorizeReport(c, moderator, id); + const note = await loadReportNote(c, report.id, noteId); + if (!canChangeNote(c, moderator, report, note, "delete")) { + throw reportError(403, { error: "forbidden" }); + } + + await c.get("db").transaction(async tx => { + await tx + .delete(core_moderation_report_events) + .where(eq(core_moderation_report_events.id, note.id)); + await appendReportEvent(tx, { + actorId: moderator.user.id, + data: { before: note.data.body ?? null, noteId: note.id }, + reportId: report.id, + type: "note_deleted", + }); + }); + + await c.get("events").emit("moderation.report.note_deleted", { + noteId: note.id, + reportId: report.id, + }); + + return c.json({ ok: true }, 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/moderation/routes/show.route.ts b/packages/vitnode/src/api/modules/moderation/routes/show.route.ts new file mode 100644 index 0000000000..f36802a9c3 --- /dev/null +++ b/packages/vitnode/src/api/modules/moderation/routes/show.route.ts @@ -0,0 +1,241 @@ +import { z } from "@hono/zod-openapi"; +import { asc, eq } from "drizzle-orm"; + +import { canOnReport, requireModerator } from "@/api/lib/moderation/access"; +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_moderation_report_events } from "@/database/moderation"; +import { + MODERATION_PERMISSIONS, + MODERATION_REPORT_STATUSES, + MODERATION_TARGET_ACTIONS, +} from "@/lib/moderation/const"; + +import { canChangeNote } from "../lib/notes"; +import { loadReportPeople } from "../lib/people"; +import { authorizeReport, relatedReports } from "../lib/reports"; +import { + errorSchema, + eventTypeSchema, + json, + reasonSchema, + reportIdParams, + reportPersonSchema, + resolutionSchema, +} from "../lib/schema"; + +const detailSchema = z.object({ + report: z.object({ + id: z.number(), + pluginId: z.string(), + targetType: z.string(), + targetId: z.string(), + reason: reasonSchema, + description: z.string().nullable(), + status: z.enum(MODERATION_REPORT_STATUSES), + resolution: resolutionSchema.nullable(), + resolutionNote: z.string().nullable(), + resolvedAt: z.date().nullable(), + createdAt: z.date(), + updatedAt: z.date(), + version: z.number(), + scope: z.object({ family: z.string(), id: z.string() }).nullable(), + }), + targetLabel: z.string().nullable(), + pluginLabel: z.string(), + snapshot: z.object({}).loose(), + authors: z.array(reportPersonSchema), + reporter: reportPersonSchema.nullable(), + assignee: reportPersonSchema.nullable(), + events: z.array( + z.object({ + id: z.number(), + type: eventTypeSchema, + actor: reportPersonSchema.nullable(), + data: z.record(z.string(), z.unknown()), + canEdit: z.boolean(), + canDelete: z.boolean(), + createdAt: z.date(), + }), + ), + related: z.array( + z.object({ + id: z.number(), + status: z.enum(MODERATION_REPORT_STATUSES), + reason: reasonSchema, + description: z.string().nullable(), + authors: z.array(reportPersonSchema), + reporter: reportPersonSchema.nullable(), + createdAt: z.date(), + }), + ), + current: z.object({ + exists: z.boolean(), + hidden: z.boolean(), + url: z.string().nullable(), + registered: z.boolean(), + }), + permissions: z.object({ + manage: z.boolean(), + actions: z.array( + z.object({ + action: z.enum(MODERATION_TARGET_ACTIONS), + allowed: z.boolean(), + available: z.boolean(), + }), + ), + }), +}); + +export const showReportRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "get", + path: "/reports/{id}", + description: + "One report with its snapshot, timeline and the related reports the moderator may see", + request: { params: reportIdParams }, + responses: { + 200: json(detailSchema, "The report"), + 401: { description: "Sign in first" }, + 403: { description: "Not a moderator" }, + 404: json( + errorSchema, + "No such report, or not one this moderator may see", + ), + }, + }, + handler: async c => { + const moderator = await requireModerator(c); + const { id } = c.req.valid("param"); + const { report, target } = await authorizeReport(c, moderator, id); + const db = c.get("db"); + + const [events, related] = await Promise.all([ + db + .select({ + actorId: core_moderation_report_events.actorId, + createdAt: core_moderation_report_events.createdAt, + data: core_moderation_report_events.data, + id: core_moderation_report_events.id, + type: core_moderation_report_events.type, + }) + .from(core_moderation_report_events) + .where(eq(core_moderation_report_events.reportId, report.id)) + .orderBy(asc(core_moderation_report_events.id)), + relatedReports(c, moderator, report), + ]); + const authorIds = Array.isArray(report.snapshot.authors) + ? report.snapshot.authors.flatMap(author => + typeof author.id === "number" ? [author.id] : [], + ) + : []; + const people = await loadReportPeople(c, [ + ...authorIds, + report.reporterId, + report.assigneeId, + ...events.map(event => event.actorId), + ...related.map(entry => entry.reporterId), + ]); + const userOf = (userId: null | number) => + userId === null ? null : (people.get(userId) ?? null); + + const locale = + typeof report.snapshot.locale?.served === "string" + ? report.snapshot.locale.served + : null; + const current = target + ? await target.definition.current(c, { + locale, + targetId: report.targetId, + }) + : null; + + const ref = { + pluginId: report.pluginId, + scopeId: report.scopeId, + targetType: report.targetType, + }; + const manage = canOnReport( + c, + moderator.set, + ref, + MODERATION_PERMISSIONS.manageReports, + ); + + return c.json( + { + report: { + id: report.id, + pluginId: report.pluginId, + targetType: report.targetType, + targetId: report.targetId, + reason: report.reason, + description: report.description, + status: report.status, + resolution: report.resolution, + resolutionNote: report.resolutionNote, + resolvedAt: report.resolvedAt, + createdAt: report.createdAt, + updatedAt: report.updatedAt, + version: report.version, + scope: + report.scopeFamily && report.scopeId + ? { family: report.scopeFamily, id: report.scopeId } + : null, + }, + targetLabel: target?.definition.label ?? null, + pluginLabel: `${report.pluginId}.title`, + snapshot: { ...report.snapshot }, + authors: authorIds.flatMap(authorId => { + const person = people.get(authorId); + + return person ? [person] : []; + }), + reporter: userOf(report.reporterId), + assignee: userOf(report.assigneeId), + events: events.map(event => ({ + id: event.id, + type: event.type, + actor: userOf(event.actorId), + data: event.data, + canEdit: + event.type === "note" && + canChangeNote(c, moderator, report, event, "edit"), + canDelete: + event.type === "note" && + canChangeNote(c, moderator, report, event, "delete"), + createdAt: event.createdAt, + })), + related: related.map(({ reporterId, ...entry }) => ({ + ...entry, + reporter: userOf(reporterId), + })), + current: { + exists: current !== null, + hidden: current?.hidden ?? false, + url: current?.url ?? null, + registered: target !== undefined, + }, + permissions: { + manage, + actions: MODERATION_TARGET_ACTIONS.flatMap(action => { + const definition = target?.definition.actions?.[action]; + if (!definition) return []; + + return [ + { + action, + allowed: + manage && + canOnReport(c, moderator.set, ref, definition.permission), + available: current !== null && definition.available(current), + }, + ]; + }), + }, + }, + 200, + ); + }, +}); diff --git a/packages/vitnode/src/api/modules/moderation/routes/workflow.routes.ts b/packages/vitnode/src/api/modules/moderation/routes/workflow.routes.ts new file mode 100644 index 0000000000..04c52de0a1 --- /dev/null +++ b/packages/vitnode/src/api/modules/moderation/routes/workflow.routes.ts @@ -0,0 +1,518 @@ +import { z } from "@hono/zod-openapi"; +import { and, eq } from "drizzle-orm"; + +import { resolveStaffPermissions } from "@/api/lib/check-staff-permission"; +import { canOnReport, requireModerator } from "@/api/lib/moderation/access"; +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_moderation_reports } from "@/database/moderation"; +import { core_users } from "@/database/users"; +import { MODERATION_PERMISSIONS } from "@/lib/moderation/const"; + +import { listModeratorCandidates } from "../lib/candidates"; +import { loadReportPeople } from "../lib/people"; +import { + appendReportEvent, + authorizeReport, + reportConflict, + reportError, + reportOpenKey, + updateReport, +} from "../lib/reports"; +import { + conflictSchema, + errorSchema, + expectedVersionBody, + json, + noteSchema, + optionalExplanationSchema, + reportIdParams, + reportPersonSchema, + reportStateSchema, + resolutionSchema, + richNoteSchema, +} from "../lib/schema"; + +const stateOf = (report: { + assigneeId: null | number; + status: "done" | "in_progress" | "new"; + version: number; +}) => ({ + assigneeId: report.assigneeId, + status: report.status, + version: report.version, +}); + +const mutationResponses = { + 200: json(reportStateSchema, "The report's new state"), + 400: { description: "Invalid input" }, + 401: { description: "Sign in first" }, + 403: json(errorSchema, "The moderator may not manage this report"), + 404: json(errorSchema, "No such report, or not one this moderator may see"), + 409: json( + conflictSchema, + "The report changed since it was read, or is in the wrong state", + ), +}; + +const body = (schema: T) => ({ + body: { content: { "application/json": { schema } } }, +}); + +export const claimReportRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "post", + path: "/reports/{id}/claim", + description: + "Take a report: assigns it to the caller and moves it to in progress", + request: { params: reportIdParams, ...body(expectedVersionBody) }, + responses: mutationResponses, + }, + handler: async c => { + const moderator = await requireModerator(c); + const { id } = c.req.valid("param"); + const { expectedVersion } = c.req.valid("json"); + const { report } = await authorizeReport(c, moderator, id, { + manage: true, + }); + + if (report.version !== expectedVersion) throw reportConflict(report); + if (report.status === "done") { + throw reportConflict(report, "report_closed"); + } + if (report.assigneeId !== null && report.assigneeId !== moderator.user.id) { + throw reportConflict(report, "already_claimed"); + } + + const updated = await c.get("db").transaction(async tx => { + const next = await updateReport(tx, report, { + assigneeId: moderator.user.id, + status: "in_progress", + }); + if (report.assigneeId !== moderator.user.id) { + await appendReportEvent(tx, { + actorId: moderator.user.id, + data: { assigneeId: moderator.user.id, previousAssigneeId: null }, + reportId: report.id, + type: "assigned", + }); + } + if (report.status !== "in_progress") { + await appendReportEvent(tx, { + actorId: moderator.user.id, + data: { from: report.status, to: "in_progress" }, + reportId: report.id, + type: "status_changed", + }); + } + + return next; + }); + + await c.get("events").emit("moderation.report.assigned", { + assigneeId: moderator.user.id, + previousAssigneeId: report.assigneeId, + reportId: report.id, + }); + + return c.json(stateOf(updated), 200); + }, +}); + +export const assignReportRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "post", + path: "/reports/{id}/assign", + description: + "Reassign a report to another moderator who may manage it, or unassign it", + request: { + params: reportIdParams, + ...body( + expectedVersionBody.extend({ + assigneeId: z.number().int().positive().nullable(), + }), + ), + }, + responses: mutationResponses, + }, + handler: async c => { + const moderator = await requireModerator(c); + const { id } = c.req.valid("param"); + const { assigneeId, expectedVersion } = c.req.valid("json"); + const { report } = await authorizeReport(c, moderator, id, { + manage: true, + }); + + if (report.version !== expectedVersion) throw reportConflict(report); + if (report.status === "done") { + throw reportConflict(report, "report_closed"); + } + if (report.assigneeId === assigneeId) { + return c.json(stateOf(report), 200); + } + + if (assigneeId !== null) { + const [user] = await c + .get("db") + .select({ id: core_users.id, roleId: core_users.roleId }) + .from(core_users) + .where(eq(core_users.id, assigneeId)) + .limit(1); + const set = user + ? await resolveStaffPermissions(c, { type: "moderator", user }) + : null; + const eligible = + set?.staff === true && + canOnReport( + c, + set, + { + pluginId: report.pluginId, + scopeId: report.scopeId, + targetType: report.targetType, + }, + MODERATION_PERMISSIONS.manageReports, + ); + if (!eligible) { + throw reportError(400, { error: "assignee_not_eligible" }); + } + } + + const status = assigneeId === null ? "new" : "in_progress"; + const updated = await c.get("db").transaction(async tx => { + const next = await updateReport(tx, report, { assigneeId, status }); + await appendReportEvent(tx, { + actorId: moderator.user.id, + data: { assigneeId, previousAssigneeId: report.assigneeId }, + reportId: report.id, + type: assigneeId === null ? "unassigned" : "assigned", + }); + if (report.status !== status) { + await appendReportEvent(tx, { + actorId: moderator.user.id, + data: { from: report.status, to: status }, + reportId: report.id, + type: "status_changed", + }); + } + + return next; + }); + + await c.get("events").emit("moderation.report.assigned", { + assigneeId, + previousAssigneeId: report.assigneeId, + reportId: report.id, + }); + + return c.json(stateOf(updated), 200); + }, +}); + +export const noteReportRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "post", + path: "/reports/{id}/notes", + description: + "Add an internal note to the report's timeline. Notes are visible to moderators only", + request: { + params: reportIdParams, + ...body(z.object({ body: richNoteSchema })), + }, + responses: { + 201: json(z.object({ ok: z.boolean() }), "Note added"), + 400: mutationResponses[400], + 401: mutationResponses[401], + 403: mutationResponses[403], + 404: mutationResponses[404], + }, + }, + handler: async c => { + const moderator = await requireModerator(c); + const { id } = c.req.valid("param"); + const { body: note } = c.req.valid("json"); + const { report } = await authorizeReport(c, moderator, id, { + manage: true, + }); + + await appendReportEvent(c.get("db"), { + actorId: moderator.user.id, + data: { body: note }, + reportId: report.id, + type: "note", + }); + + return c.json({ ok: true }, 201); + }, +}); + +export const resolveReportRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "post", + path: "/reports/{id}/resolve", + description: + "Close a report with an outcome and an optional explanation. Related reports stay open", + request: { + params: reportIdParams, + ...body( + expectedVersionBody.extend({ + resolution: resolutionSchema, + explanation: optionalExplanationSchema, + }), + ), + }, + responses: mutationResponses, + }, + handler: async c => { + const moderator = await requireModerator(c); + const { id } = c.req.valid("param"); + const { expectedVersion, explanation, resolution } = c.req.valid("json"); + const { report } = await authorizeReport(c, moderator, id, { + manage: true, + }); + + if (report.version !== expectedVersion) throw reportConflict(report); + if (report.status === "done") { + throw reportConflict(report, "report_closed"); + } + + const updated = await c.get("db").transaction(async tx => { + const next = await updateReport(tx, report, { + openKey: null, + resolution, + resolutionNote: explanation, + resolvedAt: new Date(), + resolvedById: moderator.user.id, + status: "done", + }); + await appendReportEvent(tx, { + actorId: moderator.user.id, + data: explanation ? { explanation, resolution } : { resolution }, + reportId: report.id, + type: "resolved", + }); + + return next; + }); + + await c.get("events").emit("moderation.report.resolved", { + reportId: report.id, + resolution, + }); + + return c.json(stateOf(updated), 200); + }, +}); + +export const reopenReportRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "post", + path: "/reports/{id}/reopen", + description: "Reopen a closed report. Needs a reason, kept in the timeline", + request: { + params: reportIdParams, + ...body(expectedVersionBody.extend({ reason: noteSchema })), + }, + responses: mutationResponses, + }, + handler: async c => { + const moderator = await requireModerator(c); + const { id } = c.req.valid("param"); + const { expectedVersion, reason } = c.req.valid("json"); + const { report } = await authorizeReport(c, moderator, id, { + manage: true, + }); + + if (report.version !== expectedVersion) throw reportConflict(report); + if (report.status !== "done") { + throw reportConflict(report, "report_open"); + } + + const status = report.assigneeId === null ? "new" : "in_progress"; + const updated = await c.get("db").transaction(async tx => { + const key = + report.reporterId === null + ? null + : reportOpenKey({ + pluginId: report.pluginId, + reporterId: report.reporterId, + targetId: report.targetId, + targetType: report.targetType, + }); + const [holder] = key + ? await tx + .select({ id: core_moderation_reports.id }) + .from(core_moderation_reports) + .where(and(eq(core_moderation_reports.openKey, key))) + .limit(1) + : []; + + const next = await updateReport(tx, report, { + openKey: holder ? null : key, + resolution: null, + resolutionNote: null, + resolvedAt: null, + resolvedById: null, + status, + }); + await appendReportEvent(tx, { + actorId: moderator.user.id, + data: { + previousResolution: report.resolution, + reason, + }, + reportId: report.id, + type: "reopened", + }); + + return next; + }); + + await c.get("events").emit("moderation.report.reopened", { + reportId: report.id, + }); + + return c.json(stateOf(updated), 200); + }, +}); + +export const reportActionRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "post", + path: "/reports/{id}/actions/{action}", + description: + "Hide or restore the reported content. Recorded in the report's timeline only when it succeeds", + request: { + params: reportIdParams.extend({ action: z.enum(["hide", "unhide"]) }), + ...body(expectedVersionBody), + }, + responses: { + ...mutationResponses, + 200: json( + reportStateSchema.extend({ changed: z.boolean() }), + "The report's new state, and whether the content changed", + ), + }, + }, + handler: async c => { + const moderator = await requireModerator(c); + const { action, id } = c.req.valid("param"); + const { expectedVersion } = c.req.valid("json"); + const { report, target } = await authorizeReport(c, moderator, id, { + manage: true, + }); + + if (report.version !== expectedVersion) throw reportConflict(report); + const definition = target?.definition.actions?.[action]; + if (!target || !definition) { + throw reportError(404, { error: "action_unavailable" }); + } + const ref = { + pluginId: report.pluginId, + scopeId: report.scopeId, + targetType: report.targetType, + }; + if (!canOnReport(c, moderator.set, ref, definition.permission)) { + throw reportError(403, { error: "forbidden" }); + } + + const result = await c.get("db").transaction(async tx => { + const outcome = await definition.run(c, { + actorUserId: moderator.user.id, + targetId: report.targetId, + tx, + }); + if (!outcome) return null; + if (!outcome.changed) { + return { outcome, state: stateOf(report) }; + } + + const next = await updateReport(tx, report, {}); + await appendReportEvent(tx, { + actorId: moderator.user.id, + data: { action }, + reportId: report.id, + type: "action", + }); + + return { outcome, state: stateOf(next) }; + }); + if (!result) throw reportError(404, { error: "target_gone" }); + + if (result.outcome.changed) { + await result.outcome.effects(); + await c.get("events").emit("moderation.report.action", { + action, + pluginId: report.pluginId, + reportId: report.id, + targetId: report.targetId, + targetType: report.targetType, + }); + } + + return c.json({ ...result.state, changed: result.outcome.changed }, 200); + }, +}); + +export const reportAssigneesRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "get", + path: "/reports/{id}/assignees", + description: "Moderators who may manage this report, for reassignment", + request: { params: reportIdParams }, + responses: { + 200: json(z.array(reportPersonSchema), "Eligible moderators"), + 401: mutationResponses[401], + 403: mutationResponses[403], + 404: mutationResponses[404], + }, + }, + handler: async c => { + const moderator = await requireModerator(c); + const { id } = c.req.valid("param"); + const { report } = await authorizeReport(c, moderator, id, { + manage: true, + }); + const candidates = await listModeratorCandidates(c); + + const resolved = await Promise.all( + candidates.map(async user => ({ + id: user.id, + set: await resolveStaffPermissions(c, { type: "moderator", user }), + })), + ); + const eligible = resolved + .filter( + ({ set }) => + set.staff && + canOnReport( + c, + set, + { + pluginId: report.pluginId, + scopeId: report.scopeId, + targetType: report.targetType, + }, + MODERATION_PERMISSIONS.manageReports, + ), + ) + .map(({ id }) => id); + const people = await loadReportPeople(c, eligible); + + return c.json( + eligible.flatMap(userId => { + const person = people.get(userId); + + return person ? [person] : []; + }), + 200, + ); + }, +}); diff --git a/packages/vitnode/src/api/modules/users/routes/permissions.route.ts b/packages/vitnode/src/api/modules/users/routes/permissions.route.ts index 213d475df5..0e5e5700f9 100644 --- a/packages/vitnode/src/api/modules/users/routes/permissions.route.ts +++ b/packages/vitnode/src/api/modules/users/routes/permissions.route.ts @@ -22,6 +22,16 @@ export const permissionsRoute = buildRoute({ plugin: z.string(), module: z.string(), permission: z.string(), + scope: z + .discriminatedUnion("mode", [ + z.object({ family: z.string(), mode: z.literal("all") }), + z.object({ + family: z.string(), + mode: z.literal("selected"), + ids: z.array(z.string()), + }), + ]) + .optional(), }), ), }), diff --git a/packages/vitnode/src/api/plugin.ts b/packages/vitnode/src/api/plugin.ts index 8a68cd9cc2..edc0fe3c7f 100644 --- a/packages/vitnode/src/api/plugin.ts +++ b/packages/vitnode/src/api/plugin.ts @@ -18,6 +18,7 @@ import { aiModule } from "./modules/ai/ai.module"; import { contentModule } from "./modules/content/content.module"; import { cronModule } from "./modules/cron/cron.module"; import { middlewareModule } from "./modules/middleware/middleware.module"; +import { moderationModule } from "./modules/moderation/moderation.module"; import { notificationsModule } from "./modules/notifications/notifications.module"; import { pagesModule } from "./modules/pages/pages.module"; import { queueModule } from "./modules/queue/queue.module"; @@ -41,6 +42,7 @@ export const newBuildPluginApiCore = buildApiPlugin({ modules: [ middlewareModule, usersModule, + moderationModule, notificationsModule, adminModule, aiModule, diff --git a/packages/vitnode/src/components/form/fields/input-users.tsx b/packages/vitnode/src/components/form/fields/input-users.tsx index 209a1c5ba4..ea2aa601ba 100644 --- a/packages/vitnode/src/components/form/fields/input-users.tsx +++ b/packages/vitnode/src/components/form/fields/input-users.tsx @@ -6,6 +6,7 @@ import { useTranslations } from "use-intl"; import { Avatar } from "@/components/avatar"; import { Button } from "@/components/ui/button"; import { FormMessage } from "@/components/ui/form"; +import { UserFormat } from "@/components/user-format"; import type { ItemAutoFormComponentProps } from "../auto-form"; @@ -18,11 +19,34 @@ export interface UserOption { id: number; name: string; nameCode: string; + role?: UserOptionRole; +} + +export interface UserOptionRole { + color: null | string; + prefix?: null | string; } export type PartialUserOption = Omit & Partial>; +const UserOptionName = ({ + className, + user, +}: { + className?: string; + user: PartialUserOption; +}) => + user.role ? ( + + ) : ( + {user.name} + ); + const UserAvatar = ({ size, user, @@ -59,7 +83,7 @@ export const UserOptionRow = ({ }) => (
- {user.name} + {!!user.nameCode && ( @{user.nameCode} @@ -139,7 +163,7 @@ export const AutoFormUser = ({ )} > - {current.name} + ) : ( diff --git a/packages/vitnode/src/components/side-nav.tsx b/packages/vitnode/src/components/side-nav.tsx new file mode 100644 index 0000000000..b06d4c696e --- /dev/null +++ b/packages/vitnode/src/components/side-nav.tsx @@ -0,0 +1,58 @@ +import { Link } from "@tanstack/react-router"; +import { cn } from "cn"; + +export const SideNavCard = ({ + className, + ...props +}: React.ComponentProps<"nav">) => ( +
- { - setIsOpen(false); - }} - pathname={pathname} - /> +
); }; +const SettingsNavCard = ({ + items, + pathname, +}: { + items: readonly SettingsNavItem[]; + pathname: string; +}) => { + const t = useTranslations("core.auth.settings"); + + return ( + + + + ); +}; + export const SettingsNavContent = ({ items = SETTINGS_NAV_ITEMS, pathname, @@ -151,8 +139,6 @@ export const SettingsNavContent = ({
-
- -
+ ); diff --git a/packages/vitnode/src/views/layouts/theme/header/user/user-header-model.ts b/packages/vitnode/src/views/layouts/theme/header/user/user-header-model.ts index 6fb1426699..33d76abe5c 100644 --- a/packages/vitnode/src/views/layouts/theme/header/user/user-header-model.ts +++ b/packages/vitnode/src/views/layouts/theme/header/user/user-header-model.ts @@ -1,12 +1,19 @@ import type { LucideIcon } from "lucide-react"; -import { FileIcon, KeyRoundIcon, SettingsIcon, UserIcon } from "lucide-react"; +import { + FileIcon, + KeyRoundIcon, + SettingsIcon, + ShieldIcon, + UserIcon, +} from "lucide-react"; export interface UserHeaderUser { avatarColor: string; avatarUrl?: null | string; email: string; isAdmin: boolean; + isModerator?: boolean; name: string; nameCode: string; } @@ -19,6 +26,7 @@ export type UserHeaderState = export const USER_HEADER_HREF = { adminCp: "/admin", files: "/files", + modCp: "/modcp/reports", settings: "/settings", signIn: "/login", signUp: "/register", @@ -45,6 +53,7 @@ export interface UserHeaderMenuItem { export type UserHeaderMenuItemKey = | "admin_cp" | "files" + | "mod_cp" | "my_profile" | "settings"; @@ -70,19 +79,29 @@ export const userHeaderMenu = ( { href: USER_HEADER_HREF.settings, Icon: SettingsIcon, key: "settings" }, ]; - if (!user.isAdmin) return [account]; - - return [ - account, - [ - { - href: USER_HEADER_HREF.adminCp, - Icon: KeyRoundIcon, - key: "admin_cp", - newTab: true, - }, - ], + const staff: UserHeaderMenuItem[] = [ + ...(user.isModerator + ? [ + { + href: USER_HEADER_HREF.modCp, + Icon: ShieldIcon, + key: "mod_cp" as const, + }, + ] + : []), + ...(user.isAdmin + ? [ + { + href: USER_HEADER_HREF.adminCp, + Icon: KeyRoundIcon, + key: "admin_cp" as const, + newTab: true, + }, + ] + : []), ]; + + return staff.length > 0 ? [account, staff] : [account]; }; export const userHeaderState = ({ diff --git a/packages/vitnode/src/views/moderation/modcp/board/board-card.tsx b/packages/vitnode/src/views/moderation/modcp/board/board-card.tsx new file mode 100644 index 0000000000..47a6a380f7 --- /dev/null +++ b/packages/vitnode/src/views/moderation/modcp/board/board-card.tsx @@ -0,0 +1,213 @@ +import { useDraggable } from "@dnd-kit/core"; +import { Link } from "@tanstack/react-router"; +import { cn } from "cn"; +import { FlagIcon, GripVerticalIcon, MoreHorizontalIcon } from "lucide-react"; +import { useTranslations } from "use-intl"; + +import { DateFormat } from "@/components/date-format"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { TooltipWithContent } from "@/components/ui/tooltip"; + +import type { ModcpBoardReport, ModcpBoardStatus } from "../modcp-query"; + +import { MODCP_BOARD_STATUSES } from "../modcp-query"; +import { + Chip, + Person, + REASON_TONE, + StatusLozenge, + TONE_SURFACE, +} from "../report-tone"; +import { useTargetLabel } from "../status-badge"; + +export const BOARD_CARD_CLASS = + "bg-card text-card-foreground ring-foreground/10 relative flex flex-col gap-2 rounded-lg p-3 shadow-xs ring-1"; + +export const boardCardId = (id: number): string => `report-${id}`; + +const ExtraReports = ({ row }: { row: ModcpBoardReport }) => { + const t = useTranslations("core.moderation.modcp.reports.board"); + if (row.relatedCount === 0) return null; + + return ( + + + +{row.relatedCount} + + + ); +}; + +const CardMenu = ({ + onMove, + row, +}: { + onMove: (row: ModcpBoardReport, to: ModcpBoardStatus) => void; + row: ModcpBoardReport; +}) => { + const t = useTranslations("core.moderation.modcp"); + + return ( + { + event.stopPropagation(); + }} + > + + + } + > + + + {t("detail.status.move_to")} + {MODCP_BOARD_STATUSES.filter(status => status !== row.status).map( + status => ( + { + onMove(row, status); + }} + > + + + ), + )} + + + + ); +}; + +export const BoardCardBody = ({ + handle, + onMove, + row, +}: { + handle?: React.ReactNode; + onMove?: (row: ModcpBoardReport, to: ModcpBoardStatus) => void; + row: ModcpBoardReport; +}) => { + const t = useTranslations("core.moderation"); + const targetLabel = useTargetLabel(); + const title = row.title ?? t("modcp.reports.untitled"); + + return ( + <> +
+ + + {t(`reasons.${row.reason}`)} + + + + + {handle} + {onMove ? : null} + +
+ + {onMove ? ( + + {title} + + ) : ( + + {title} + + )} + +

+ #{row.id} · {targetLabel(row.targetLabel, row.targetType)} +

+ +
+ + {row.assignee ? ( + + ) : ( + + + + )} + + + + +
+ + ); +}; + +export const BoardCard = ({ + onMove, + row, +}: { + onMove: (row: ModcpBoardReport, to: ModcpBoardStatus) => void; + row: ModcpBoardReport; +}) => { + const t = useTranslations("core.moderation.modcp.reports.board"); + const { attributes, isDragging, listeners, setActivatorNodeRef, setNodeRef } = + useDraggable({ data: { reportId: row.id }, id: boardCardId(row.id) }); + + return ( +
  • + +
  • + ); +}; diff --git a/packages/vitnode/src/views/moderation/modcp/board/board-moves.ts b/packages/vitnode/src/views/moderation/modcp/board/board-moves.ts new file mode 100644 index 0000000000..5cc6bc2064 --- /dev/null +++ b/packages/vitnode/src/views/moderation/modcp/board/board-moves.ts @@ -0,0 +1,104 @@ +import type { ModcpBoardReport, ModcpBoardStatus } from "../modcp-query"; + +export type BoardPerson = NonNullable; + +export interface BoardOverride { + assignee: BoardPerson | null; + status: ModcpBoardStatus; +} + +export type BoardMove = + | { kind: "claim" } + | { kind: "reopen"; to: Exclude } + | { kind: "resolve" } + | { kind: "unassign" }; + +export const planBoardMove = ( + from: ModcpBoardStatus, + to: ModcpBoardStatus, +): BoardMove | null => { + if (from === to) return null; + if (to === "done") return { kind: "resolve" }; + if (from === "done") return { kind: "reopen", to }; + + return to === "in_progress" ? { kind: "claim" } : { kind: "unassign" }; +}; + +export const optimisticOverride = ({ + me, + row, + to, +}: { + me: BoardPerson; + row: ModcpBoardReport; + to: ModcpBoardStatus; +}): BoardOverride => ({ + assignee: to === "new" ? null : (row.assignee ?? me), + status: to, +}); + +const timeOf = (value: Date | string): number => new Date(value).getTime(); + +export const newestFirst = ( + left: Pick, + right: Pick, +): number => + timeOf(right.createdAt) - timeOf(left.createdAt) || right.id - left.id; + +export type BoardColumns = Record; + +export const boardColumns = ({ + loaded, + overrides, +}: { + loaded: BoardColumns; + overrides: ReadonlyMap; +}): BoardColumns => { + const latest = new Map(); + for (const row of [...loaded.new, ...loaded.in_progress, ...loaded.done]) { + const known = latest.get(row.id); + if (!known || row.version > known.version) latest.set(row.id, row); + } + + const columns: BoardColumns = { done: [], in_progress: [], new: [] }; + for (const row of latest.values()) { + const override = overrides.get(row.id); + const shown = override ? { ...row, ...override } : row; + columns[shown.status].push(shown); + } + + for (const rows of Object.values(columns)) rows.sort(newestFirst); + + return columns; +}; + +export const columnCount = ({ + loaded, + overrides, + status, + total, +}: { + loaded: BoardColumns; + overrides: ReadonlyMap; + status: ModcpBoardStatus; + total: number; +}): number => { + let count = total; + for (const row of [...loaded.new, ...loaded.in_progress, ...loaded.done]) { + const override = overrides.get(row.id); + if (!override || override.status === row.status) continue; + if (override.status === status) count += 1; + if (row.status === status) count -= 1; + } + + return Math.max(0, count); +}; + +export const slotIndex = ( + rows: readonly ModcpBoardReport[], + dragged: Pick, +): number => { + const index = rows.findIndex(row => newestFirst(dragged, row) < 0); + + return index === -1 ? rows.length : index; +}; diff --git a/packages/vitnode/src/views/moderation/modcp/board/reports-board.tsx b/packages/vitnode/src/views/moderation/modcp/board/reports-board.tsx new file mode 100644 index 0000000000..7559e68285 --- /dev/null +++ b/packages/vitnode/src/views/moderation/modcp/board/reports-board.tsx @@ -0,0 +1,606 @@ +import type { + Announcements, + CollisionDetection, + DragEndEvent, + DragOverEvent, + DragStartEvent, + KeyboardCoordinateGetter, +} from "@dnd-kit/core"; + +import { + DndContext, + DragOverlay, + KeyboardSensor, + pointerWithin, + rectIntersection, + TouchSensor, + useDroppable, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { restrictToWindowEdges } from "@dnd-kit/modifiers"; +import { + useQueryClient, + useSuspenseInfiniteQuery, +} from "@tanstack/react-query"; +import { cn } from "cn"; +import { useReducedMotion } from "motion/react"; +import React from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import type { LayoutFlipTarget, LayoutSnapshot } from "@/lib/dnd/layout-flip"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { DragLift } from "@/lib/dnd/drag-lift"; +import { + DRAG_OVERLAY_LIFT_CLASS, + LIFTED_DROP_ANIMATION, +} from "@/lib/dnd/drag-motion"; +import { playLayoutFlip, snapshotLayout } from "@/lib/dnd/layout-flip"; +import { FinePointerSensor } from "@/lib/dnd/sensors"; + +import type { ReportState } from "../../moderation-mutations"; +import type { + ModcpBoardFilter, + ModcpBoardReport, + ModcpBoardStatus, +} from "../modcp-query"; +import type { BoardOverride, BoardPerson } from "./board-moves"; + +import { assignReport, claimReport } from "../../moderation-mutations"; +import { + invalidateModcp, + MODCP_BOARD_STATUSES, + modcpBoardColumnQueryOptions, +} from "../modcp-query"; +import { ModcpPluginMessages } from "../plugin-messages"; +import { + ReopenReportForm, + ResolveReportForm, + useReportFailure, +} from "../report-forms"; +import { StatusLozenge } from "../report-tone"; +import { BOARD_CARD_CLASS, BoardCard, BoardCardBody } from "./board-card"; +import { + boardColumns, + columnCount, + optimisticOverride, + planBoardMove, + slotIndex, +} from "./board-moves"; + +const COLUMN_PREFIX = "column-"; + +const CARDS: LayoutFlipTarget = { + keyOf: node => node.dataset.reportCard, + selector: "[data-report-card]", +}; + +const MIN_SLOT_HEIGHT = 96; + +const reportIdOf = (id: number | string): null | number => { + const match = /^report-(\d+)$/.exec(String(id)); + + return match ? Number(match[1]) : null; +}; + +const columnStatusOf = ( + id: null | number | string | undefined, +): ModcpBoardStatus | null => { + if (id === null || id === undefined) return null; + const value = String(id); + if (!value.startsWith(COLUMN_PREFIX)) return null; + const status = value.slice(COLUMN_PREFIX.length); + + return (MODCP_BOARD_STATUSES as readonly string[]).includes(status) + ? (status as ModcpBoardStatus) + : null; +}; + +const KEYBOARD_DROP_OFFSET = 48; + +const ARROW_STEP: Record = { + ArrowDown: 1, + ArrowLeft: -1, + ArrowRight: 1, + ArrowUp: -1, +}; + +const columnCoordinates: KeyboardCoordinateGetter = ( + event, + { context: { collisionRect, droppableContainers, droppableRects } }, +) => { + const step = ARROW_STEP[event.code]; + if (step === undefined || !collisionRect) return undefined; + event.preventDefault(); + + const columns = droppableContainers + .getEnabled() + .flatMap(container => { + const rect = droppableRects.get(container.id); + + return rect ? [rect] : []; + }) + .sort((left, right) => left.left - right.left || left.top - right.top); + if (columns.length === 0) return undefined; + + const centerX = collisionRect.left + collisionRect.width / 2; + const centerY = collisionRect.top + collisionRect.height / 2; + const current = columns.findIndex( + rect => + centerX >= rect.left && + centerX <= rect.left + rect.width && + centerY >= rect.top && + centerY <= rect.top + rect.height, + ); + const target = + columns[Math.min(columns.length - 1, Math.max(0, current + step))]; + if (!target) return undefined; + + return { + x: target.left + (target.width - collisionRect.width) / 2, + y: target.top + KEYBOARD_DROP_OFFSET, + }; +}; + +const collisionDetection: CollisionDetection = args => { + const within = pointerWithin(args); + + return within.length > 0 ? within : rectIntersection(args); +}; + +type Pending = null | { + kind: "reopen" | "resolve"; + row: ModcpBoardReport; + to: ModcpBoardStatus; +}; + +const DropSlot = ({ height, hint }: { height: number; hint: string }) => ( + +); + +const BoardColumn = ({ + count, + hasMore, + isLoadingMore, + onLoadMore, + onMove, + rows, + slot, + status, +}: { + count: number; + hasMore: boolean; + isLoadingMore: boolean; + onLoadMore: () => void; + onMove: (row: ModcpBoardReport, to: ModcpBoardStatus) => void; + rows: ModcpBoardReport[]; + slot: null | { height: number; hint: string; index: number }; + status: ModcpBoardStatus; +}) => { + const t = useTranslations("core.moderation.modcp.reports.board"); + const headingId = React.useId(); + const { setNodeRef } = useDroppable({ id: `${COLUMN_PREFIX}${status}` }); + const slotElement = + slot === null ? null : ( + + ); + + return ( +
    +

    + + + {count} + +

    + + {rows.length === 0 && slot === null ? ( +

    + {t("nothing_here")} +

    + ) : ( +
      + {rows.map((row, index) => ( + + {slot?.index === index ? slotElement : null} + + + ))} + {slot !== null && slot.index >= rows.length ? slotElement : null} +
    + )} + + {hasMore ? ( + + ) : null} +
    + ); +}; + +const BoardDialog = ({ + onCancel, + onDone, + onReopened, + pending, +}: { + onCancel: () => void; + onDone: () => Promise; + onReopened: (state: ReportState) => Promise; + pending: Pending; +}) => { + const t = useTranslations("core.moderation.modcp.detail"); + const row = pending?.row; + + return ( + { + if (!open) onCancel(); + }} + open={pending !== null} + > + + + + {pending?.kind === "reopen" + ? t("reopen.dialog_title", { id: row?.id ?? 0 }) + : t("resolve.dialog_title", { id: row?.id ?? 0 })} + + + {pending?.kind === "reopen" + ? t("reopen.dialog_desc") + : t("resolve.success_desc")} + + + {row === undefined ? null : pending?.kind === "reopen" ? ( + + ) : ( + + )} + + + ); +}; + +export const ModcpReportsBoard = ({ + filter, + me, +}: { + filter: ModcpBoardFilter; + me: BoardPerson; +}) => { + const t = useTranslations("core.moderation"); + const queryClient = useQueryClient(); + const reduceMotion = useReducedMotion(); + const fail = useReportFailure(); + const newColumn = useSuspenseInfiniteQuery( + modcpBoardColumnQueryOptions("new", filter), + ); + const progressColumn = useSuspenseInfiniteQuery( + modcpBoardColumnQueryOptions("in_progress", filter), + ); + const doneColumn = useSuspenseInfiniteQuery( + modcpBoardColumnQueryOptions("done", filter), + ); + const queries = { + done: doneColumn, + in_progress: progressColumn, + new: newColumn, + }; + + const [overrides, setOverrides] = React.useState< + ReadonlyMap + >(() => new Map()); + const [activeId, setActiveId] = React.useState(null); + const [overStatus, setOverStatus] = React.useState( + null, + ); + const [dragHeight, setDragHeight] = React.useState(0); + const [pending, setPending] = React.useState(null); + const boardRef = React.useRef(null); + const flipFromRef = React.useRef(null); + + const loaded = { + done: doneColumn.data.pages.flatMap(page => page.items), + in_progress: progressColumn.data.pages.flatMap(page => page.items), + new: newColumn.data.pages.flatMap(page => page.items), + }; + const columns = boardColumns({ loaded, overrides }); + const allRows = [...columns.new, ...columns.in_progress, ...columns.done]; + const active = + activeId === null + ? null + : (allRows.find(row => row.id === activeId) ?? null); + + const prepareFlip = () => { + if (reduceMotion) return; + flipFromRef.current ??= snapshotLayout(boardRef.current, CARDS); + }; + + React.useLayoutEffect(() => { + playLayoutFlip(boardRef.current, flipFromRef.current, CARDS); + flipFromRef.current = null; + }); + + const setOverride = (id: number, next: BoardOverride | null) => { + prepareFlip(); + setOverrides(current => { + const copy = new Map(current); + if (next === null) copy.delete(id); + else copy.set(id, next); + + return copy; + }); + }; + + const settle = async (id: number) => { + await invalidateModcp(queryClient); + setOverride(id, null); + }; + + const hintFor = (from: ModcpBoardStatus, to: ModcpBoardStatus): string => { + const plan = planBoardMove(from, to); + if (plan?.kind === "claim") return t("modcp.reports.board.hint_take"); + if (plan?.kind === "unassign") { + return t("modcp.reports.board.hint_unassign"); + } + if (plan?.kind === "resolve") return t("modcp.reports.board.hint_close"); + + return t("modcp.reports.board.hint_reopen"); + }; + + const move = async (row: ModcpBoardReport, to: ModcpBoardStatus) => { + const plan = planBoardMove(row.status, to); + if (plan === null) return; + + setOverride(row.id, optimisticOverride({ me, row, to })); + + if (plan.kind === "resolve" || plan.kind === "reopen") { + setPending({ kind: plan.kind, row, to }); + + return; + } + + const result = + plan.kind === "claim" + ? await claimReport({ expectedVersion: row.version, id: row.id }) + : await assignReport({ + assigneeId: null, + expectedVersion: row.version, + id: row.id, + }); + + if (!fail(result)) { + if (plan.kind === "claim") { + toast.success(t("modcp.detail.assignment.claimed"), { + description: t("modcp.detail.assignment.claimed_desc"), + }); + } else { + toast.success(t("modcp.reports.board.unassigned"), { + description: t("modcp.reports.board.unassigned_desc"), + }); + } + } + await settle(row.id); + }; + + const onReopened = async (state: ReportState) => { + if (pending === null) return; + const { row, to } = pending; + if (to === "in_progress" && state.status === "new") { + fail(await claimReport({ expectedVersion: state.version, id: row.id })); + } + if (to === "new" && state.status === "in_progress") { + fail( + await assignReport({ + assigneeId: null, + expectedVersion: state.version, + id: row.id, + }), + ); + } + }; + + const sensors = useSensors( + useSensor(FinePointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(TouchSensor, { + activationConstraint: { delay: 200, tolerance: 8 }, + }), + useSensor(KeyboardSensor, { coordinateGetter: columnCoordinates }), + ); + + const statusName = (status: ModcpBoardStatus) => t(`statuses.${status}`); + + const announcements: Announcements = { + onDragCancel: ({ active: dragged }) => + t("modcp.reports.board.dnd.cancelled", { + id: reportIdOf(dragged.id) ?? 0, + }), + onDragEnd: ({ active: dragged, over }) => { + const status = columnStatusOf(over?.id); + const id = reportIdOf(dragged.id) ?? 0; + + return status === null + ? t("modcp.reports.board.dnd.cancelled", { id }) + : t("modcp.reports.board.dnd.dropped", { + column: statusName(status), + id, + }); + }, + onDragOver: ({ active: dragged, over }) => { + const status = columnStatusOf(over?.id); + const id = reportIdOf(dragged.id) ?? 0; + + return status === null + ? t("modcp.reports.board.dnd.outside", { id }) + : t("modcp.reports.board.dnd.over", { + column: statusName(status), + id, + }); + }, + onDragStart: ({ active: dragged }) => + t("modcp.reports.board.dnd.picked_up", { + id: reportIdOf(dragged.id) ?? 0, + }), + }; + + const onDragStart = ({ active: dragged }: DragStartEvent) => { + setActiveId(reportIdOf(dragged.id)); + setDragHeight(dragged.rect.current.initial?.height ?? 0); + }; + + const onDragOver = ({ active: dragged, over }: DragOverEvent) => { + const status = columnStatusOf(over?.id); + const height = dragged.rect.current.initial?.height ?? 0; + if (height !== dragHeight) setDragHeight(height); + if (status === overStatus) return; + prepareFlip(); + setOverStatus(status); + }; + + const reset = () => { + prepareFlip(); + setActiveId(null); + setOverStatus(null); + }; + + const onDragEnd = ({ over }: DragEndEvent) => { + const status = columnStatusOf(over?.id); + const row = active; + reset(); + if (row !== null && status !== null) void move(row, status); + }; + + const showsSlot = + active !== null && overStatus !== null && overStatus !== active.status; + + return ( + row.targetLabel)}> + +
    + {MODCP_BOARD_STATUSES.map(status => { + const query = queries[status]; + const rows = columns[status]; + + return ( + { + void query.fetchNextPage(); + }} + onMove={(row, to) => { + void move(row, to); + }} + rows={rows} + slot={ + showsSlot && overStatus === status + ? { + height: dragHeight, + hint: hintFor(active.status, status), + index: slotIndex(rows, active), + } + : null + } + status={status} + /> + ); + })} +
    + + + {active === null ? null : ( + +
    + +
    +
    + )} +
    + + { + if (pending !== null) setOverride(pending.row.id, null); + setPending(null); + }} + onDone={async () => { + const id = pending?.row.id; + setPending(null); + if (id !== undefined) await settle(id); + }} + onReopened={onReopened} + pending={pending} + /> +
    +
    + ); +}; diff --git a/packages/vitnode/src/views/moderation/modcp/modcp-nav.tsx b/packages/vitnode/src/views/moderation/modcp/modcp-nav.tsx new file mode 100644 index 0000000000..2e3c2d596d --- /dev/null +++ b/packages/vitnode/src/views/moderation/modcp/modcp-nav.tsx @@ -0,0 +1,112 @@ +import { useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { cn } from "cn"; +import { FlagIcon } from "lucide-react"; +import { useTranslations } from "use-intl"; + +import { SideNavCard, SideNavLink, SideNavList } from "@/components/side-nav"; + +import { modcpReportsQueryOptions } from "./modcp-query"; + +export type ModcpNavKey = "reports"; + +interface ModcpNavItem { + href: string; + icon: React.ComponentType<{ className?: string }>; + key: ModcpNavKey; +} + +export const MODCP_NAV_ITEMS: readonly ModcpNavItem[] = [ + { href: "/modcp/reports", icon: FlagIcon, key: "reports" }, +]; + +const OPEN_REPORTS_FILTER = { + assigned: "any", + page: 1, + pageSize: 1, + status: "open", +} as const; + +export const isModcpNavItemActive = ( + item: ModcpNavItem, + pathname: string, +): boolean => pathname === item.href || pathname.startsWith(`${item.href}/`); + +const useNavCounts = (): Partial> => { + const { data } = useQuery(modcpReportsQueryOptions(OPEN_REPORTS_FILTER)); + + return { reports: data?.total }; +}; + +const ModcpNavChip = ({ + count, + isActive, + item, +}: { + count?: number; + isActive: boolean; + item: ModcpNavItem; +}) => { + const t = useTranslations("core.moderation.modcp"); + const Icon = item.icon; + + return ( + +