diff --git a/apps/api/migrations/20261024195452_add_core_audit_logs/migration.sql b/apps/api/migrations/20261024195452_add_core_audit_logs/migration.sql new file mode 100644 index 0000000000..49ec5698d3 --- /dev/null +++ b/apps/api/migrations/20261024195452_add_core_audit_logs/migration.sql @@ -0,0 +1,38 @@ +CREATE TABLE "core_audit_logs" ( + "id" bigserial PRIMARY KEY, + "pluginId" varchar(255) NOT NULL, + "area" varchar(16) NOT NULL, + "action" varchar(150) NOT NULL, + "actorType" varchar(16) NOT NULL, + "actorId" integer, + "actorName" varchar(255), + "targetType" varchar(100) NOT NULL, + "targetId" varchar(64), + "targetLabel" varchar(255), + "changes" jsonb, + "metadata" jsonb DEFAULT '{}' NOT NULL, + "contentTypeId" varchar(100), + "revisionId" integer, + "scopeFamily" varchar(100), + "scopeId" varchar(64), + "ipAddress" varchar(45), + "correlationId" varchar(64), + "createdAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "core_audit_logs" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "core_audit_settings" ( + "id" integer PRIMARY KEY DEFAULT 1, + "retentionDays" integer DEFAULT 365 NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "core_audit_settings" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE INDEX "core_audit_logs_created_at_id_idx" ON "core_audit_logs" ("createdAt","id");--> statement-breakpoint +CREATE INDEX "core_audit_logs_actor_idx" ON "core_audit_logs" ("actorId","createdAt");--> statement-breakpoint +CREATE INDEX "core_audit_logs_plugin_idx" ON "core_audit_logs" ("pluginId","createdAt");--> statement-breakpoint +CREATE INDEX "core_audit_logs_action_idx" ON "core_audit_logs" ("action","createdAt");--> statement-breakpoint +CREATE INDEX "core_audit_logs_area_idx" ON "core_audit_logs" ("area","createdAt");--> statement-breakpoint +CREATE INDEX "core_audit_logs_target_idx" ON "core_audit_logs" ("pluginId","targetType","targetId","createdAt");--> statement-breakpoint +CREATE INDEX "core_audit_logs_scope_idx" ON "core_audit_logs" ("scopeFamily","scopeId","createdAt");--> statement-breakpoint +ALTER TABLE "core_audit_logs" ADD CONSTRAINT "core_audit_logs_actorId_core_users_id_fkey" FOREIGN KEY ("actorId") REFERENCES "core_users"("id") ON DELETE SET NULL ON UPDATE CASCADE; \ No newline at end of file diff --git a/apps/api/migrations/20261024195452_add_core_audit_logs/snapshot.json b/apps/api/migrations/20261024195452_add_core_audit_logs/snapshot.json new file mode 100644 index 0000000000..34432ae6aa --- /dev/null +++ b/apps/api/migrations/20261024195452_add_core_audit_logs/snapshot.json @@ -0,0 +1,20775 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "da3c6b6c-bb28-4684-801a-35db2375a151", + "prevIds": [ + "0c989133-91e8-46c3-86d3-f9b7ac8ec7a3" + ], + "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_audit_logs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_audit_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_containers", + "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_submissions", + "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_report_reasons", + "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_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_search_popularity", + "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_update_lock", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_update_results", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_update_sources", + "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_discussions", + "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" + }, + { + "isRlsEnabled": true, + "name": "forum_comments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "forum_topics", + "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": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "area", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(150)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorType", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorName", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetType", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetId", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetLabel", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "changes", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revisionId", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeFamily", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeId", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(45)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "correlationId", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_audit_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "365", + "generated": null, + "identity": null, + "name": "retentionDays", + "entityType": "columns", + "schema": "public", + "table": "core_audit_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_audit_settings" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "definitionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parentId", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "policies", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "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_submissions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerDefinitionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "values", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseValues", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseVersion", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseUpdatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reviewerId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "decidedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "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_report_reasons" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "requiresDetails", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "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": "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": "itemType", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "logScore", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "views", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "clicks", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "comments", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "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": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_update_lock" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "owner", + "entityType": "columns", + "schema": "public", + "table": "core_update_lock" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_update_lock" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastManualAt", + "entityType": "columns", + "schema": "public", + "table": "core_update_lock" + }, + { + "type": "varchar(214)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "componentId", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceUrl", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "versions", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "hasUnknownVersion", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "varchar(256)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coreVersion", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "result", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastAttemptAt", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastSuccessAt", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "etag", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastModified", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "document", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastAttemptAt", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastSuccessAt", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "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": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "friendlyUrl", + "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": "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": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "board", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "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" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "topic", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parent", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "forum", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "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": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_expires_at_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": "userId", + "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_user_id_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": "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_audit_logs_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_audit_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorId", + "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_audit_logs_actor_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_audit_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "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_audit_logs_plugin_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_audit_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "action", + "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_audit_logs_action_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_audit_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "area", + "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_audit_logs_area_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_audit_logs" + }, + { + "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 + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_audit_logs_target_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_audit_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "scopeFamily", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scopeId", + "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_audit_logs_scope_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_audit_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "definitionId", + "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": "core_content_containers_definition_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "core_content_containers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "definitionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "parentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_containers_definition_parent_position_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_containers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_containers_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_containers" + }, + { + "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": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"status\" = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_submissions_pending_key", + "entityType": "indexes", + "schema": "public", + "table": "core_content_submissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "containerId", + "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_content_submissions_queue_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_submissions" + }, + { + "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_submissions_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_submissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorId", + "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_content_submissions_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_submissions" + }, + { + "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 + }, + { + "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_user_created_at_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": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_report_reasons_position_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "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": "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_moderation_reports_created_at_id_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": "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": "eventId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"eventId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_event_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": "completedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"completedAt\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_completed_at_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": "to_tsvector('simple'::regconfig, \"title\")", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_title_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": "containerType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "containerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_container_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "logScore", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_popularity_log_score_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_popularity" + }, + { + "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": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_expires_at_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_role_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": "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_categories_translations_language_id_friendly_url_key", + "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": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_discussions_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_discussions_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "board", + "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_discussions_board_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_discussions_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_discussions_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_discussions" + }, + { + "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_discussions_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_discussions_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_discussions" + }, + { + "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": true, + "columns": [ + { + "value": "topic", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "parent", + "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": "forum_comments_topic_parent_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "topic", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_topic_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parent", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "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": "forum_comments_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "forum", + "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": "forum_topics_forum_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "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": "forum_topics_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "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": [ + "actorId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_audit_logs_actorId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_audit_logs" + }, + { + "nameExplicit": false, + "columns": [ + "parentId" + ], + "schemaTo": "public", + "tableTo": "core_content_containers", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "core_content_containers_q6QIucJ4HJ7B_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_containers" + }, + { + "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": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_content_submissions_authorId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_submissions" + }, + { + "nameExplicit": false, + "columns": [ + "reviewerId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_content_submissions_reviewerId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_submissions" + }, + { + "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": [ + "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_discussions_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_discussions_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": false, + "columns": [ + "board" + ], + "schemaTo": "public", + "tableTo": "core_content_containers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_discussions_board_core_content_containers_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_discussions" + }, + { + "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" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_replies_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_replies_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": false, + "columns": [ + "topic" + ], + "schemaTo": "public", + "tableTo": "forum_topics", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "forum_replies_topic_forum_topics_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": false, + "columns": [ + "parent" + ], + "schemaTo": "public", + "tableTo": "forum_comments", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_replies_parent_forum_replies_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_topics_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_topics_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": false, + "columns": [ + "forum" + ], + "schemaTo": "public", + "tableTo": "core_content_containers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "forum_topics_forum_core_content_containers_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_topics" + }, + { + "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": [ + "itemType", + "itemId" + ], + "nameExplicit": false, + "name": "core_search_popularity_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_search_popularity" + }, + { + "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_audit_logs_pkey", + "schema": "public", + "table": "core_audit_logs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_audit_settings_pkey", + "schema": "public", + "table": "core_audit_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_containers_pkey", + "schema": "public", + "table": "core_content_containers", + "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_submissions_pkey", + "schema": "public", + "table": "core_content_submissions", + "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_report_reasons_pkey", + "schema": "public", + "table": "core_moderation_report_reasons", + "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_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": [ + "name" + ], + "nameExplicit": false, + "name": "core_update_lock_pkey", + "schema": "public", + "table": "core_update_lock", + "entityType": "pks" + }, + { + "columns": [ + "componentId" + ], + "nameExplicit": false, + "name": "core_update_results_pkey", + "schema": "public", + "table": "core_update_results", + "entityType": "pks" + }, + { + "columns": [ + "url" + ], + "nameExplicit": false, + "name": "core_update_sources_pkey", + "schema": "public", + "table": "core_update_sources", + "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_discussions_pkey", + "schema": "public", + "table": "example_discussions", + "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" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "forum_replies_pkey", + "schema": "public", + "table": "forum_comments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "forum_topics_pkey", + "schema": "public", + "table": "forum_topics", + "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": [ + "code" + ], + "nullsNotDistinct": false, + "name": "core_moderation_report_reasons_code_key", + "schema": "public", + "table": "core_moderation_report_reasons", + "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/activity-logs-content-panel.webp b/apps/web/content/docs/content-engine/activity-logs-content-panel.webp new file mode 100644 index 0000000000..e6cd142525 Binary files /dev/null and b/apps/web/content/docs/content-engine/activity-logs-content-panel.webp differ diff --git a/apps/web/content/docs/content-engine/activity-logs.mdx b/apps/web/content/docs/content-engine/activity-logs.mdx new file mode 100644 index 0000000000..e4340ef9d5 --- /dev/null +++ b/apps/web/content/docs/content-engine/activity-logs.mdx @@ -0,0 +1,122 @@ +--- +title: Activity logs +description: How VitNode records every Content Engine write in the Activity Logs automatically, inside the write's transaction, and how to tune or disable it per content type with the audit option. +icon: FileClock +--- + +import { ImgDocs } from '@/components/fumadocs/img' + +import contentPanel from './activity-logs-content-panel.webp' + +Every content type is audited without any code: creating, editing, deleting, +duplicating, publishing, hiding, restoring, scheduling and every translation +change land in the [Activity Logs](/docs/dev/audit-logs) with the actor, the record, the changed fields +and, for an editorial content type, the revision it created. This page explains +what is recorded and how to tune it. + +## What an entry contains + +| Field | Value | +| ----------------- | ----------------------------------------------------------------------------------------------- | +| Plugin | The content type's plugin, from the content registry - never from the request | +| Action | `content.created`, `content.updated`, `content.translation_published`... | +| Target | The content type id (`blog.category`), the record id and its title at the time | +| Changed fields | The canonical paths the write moved (`seo.title`, `categories`) | +| Changes | Without `editorial`: bounded before/after values. With it: none - the revision holds them | +| Revision | The revision the write created, shown as a diff against the previous one | +| Locale | For translation operations | + +Delivery events (`delivery_slug_changed`, `delivery_redirect_created`) are not +separate entries: the slug change is part of the `content.updated` entry. + +## Written in the transaction, not by an event listener + +Content events are emitted after a write commits, so a listener cannot make the +audit trail atomic. The Content Engine writes the entry **inside** the write's +transaction instead: beside the revision for an editorial content type, and in +the plain service's transaction otherwise. A rolled-back write leaves no entry, +and a write whose entry cannot be stored does not commit. + +This holds for the generated AdminCP routes, the public write API, approvals, +moderation actions and your own code calling `model.service(c)` or +`model.editorialService(c)` - they all go through the same services. + +## One operation, one entry + +Some operations write more than one thing. The Content Engine records the +operation the person asked for: + +| Operation | Entries | +| ------------------------------------------------ | ----------------------------------------------------------------------------- | +| Create a localized record with its translations | One `content.created`, labelled with the default translation's title, listing the locales | +| Publish a localized record | One `content.published` listing the languages it published | +| Duplicate a record | One `content.duplicated` on the copy, with the source id | +| Save shared fields and a translation together | One entry per part that moved, sharing a request id | + +## Scheduled publishing + +A scheduled publish is executed by the system, so its entry has the **System** +actor and `area: system`. Who booked it travels in the metadata: + +```json +{ + "action": "content.published", + "actorType": "system", + "metadata": { "scheduledByUserId": 12, "scheduleId": 40, "trigger": "scheduled_task" } +} +``` + +Booking and cancelling the schedule are entries of their own +(`content.scheduled`, `content.schedule_cancelled`) attributed to the admin. +The execution runs in the schedule's claim transaction, so a retried run never +records twice. + +## Tune or disable it + +Auditing is on by default. Pass `audit` to `defineContentType` to keep noisy +fields out of the recorded changes, or `false` to turn it off. + +```ts title="plugins/shop/src/content/product.ts" +import { defineContentType, field } from '@vitnode/core/content' + +export const productContentType = defineContentType({ + id: 'shop.product', + tableName: 'shop_products', + fields: { + name: field.text({ required: true, maxLength: 200 }), + stock: field.number({ integer: true, required: true }), + }, + // Stock moves with every order; the entry still says the product changed. + audit: { excludeFields: ['stock'] }, // [!code ++] +}) +``` + +| Option | Type | Default | Description | +| --------------- | ---------------------- | ------- | --------------------------------------------------------------------------- | +| `excludeFields` | field names | `[]` | Never named or diffed in an entry. The write itself is still recorded. | +| `trackChanges` | `boolean` | `true` | `false` records the operation only, without changed fields or values. | + +`audit: false` records nothing for the content type - use it only for +high-volume, non-sensitive content. Sensitive field names (passwords, tokens, +keys) are redacted whatever this says. + +## See a record's history in the AdminCP + +Every content list has an **Activity** row action. It opens the record's +entries, newest first, with their changes - for any content type, editorial or +not. An admin needs `can_view` on the content type, or Activity Logs +`can_view`. + + + +To show the same history elsewhere in the AdminCP, render `ActivityHistory`: + +```tsx +import { ActivityHistory } from '@vitnode/core/views/activity-logs/activity-history' + + +``` diff --git a/apps/web/content/docs/content-engine/meta.json b/apps/web/content/docs/content-engine/meta.json index 228837fc92..13ec5f0959 100644 --- a/apps/web/content/docs/content-engine/meta.json +++ b/apps/web/content/docs/content-engine/meta.json @@ -47,6 +47,7 @@ "editorial-scheduling", "live-editing", "duplicate-content", + "activity-logs", "---Customization---", "admincp", "services", diff --git a/apps/web/content/docs/dev/audit-logs/access-and-retention.mdx b/apps/web/content/docs/dev/audit-logs/access-and-retention.mdx new file mode 100644 index 0000000000..8386a552a9 --- /dev/null +++ b/apps/web/content/docs/dev/audit-logs/access-and-retention.mdx @@ -0,0 +1,64 @@ +--- +title: Access and retention +description: Who may read the VitNode admin and moderator logs in AdminCP Staff, how long entries are kept, and how to export them as CSV. +icon: ShieldCheck +--- + +The Activity Logs hold a lot of who-did-what, so reading them is an admin +permission of its own, and entries expire after a retention period you choose. +All of it is enforced by the API; the UI only hides what you could not open +anyway. + +## Permissions + +Grant them in **AdminCP → Staff** like any other staff permission. + +| Permission (module `activity_logs`) | Type | Allows | +| ----------------------------------- | --------- | ------------------------------------------------------------------------- | +| `can_view` | Admin | **Staff → Administrators → Logs** and **Staff → Moderators → Logs**, entry details, every record's Activity panel | +| `can_view_sensitive` | Admin | IP addresses and request ids in details and exports | +| `can_export` | Admin | **Export CSV** with the current filters | +| `can_manage_retention` | Admin | Changing the retention period | + +A root role holds them all. An admin without `can_view` can still open the +**Activity** panel of a content record when they hold `can_view` on that +content type. + +## Two logs, one table + +Both staff logs read the same entries, split by the area they were recorded in: + +| Page | Shows | +| ------------------------------------- | -------------------------------------------------------------------------- | +| **Staff → Administrators → Logs** | The `admin`, `public` and `system` areas: AdminCP changes, member writes, scheduled and background jobs | +| **Staff → Moderators → Logs** | The `moderator` area: report workflow steps, approvals, content hidden from a report | + +The **Area** column only shows when the list mixes areas, so the moderators' +log never has one. Both pages, the details and the export need the same admin +permissions. +Moderators do not get a log in the ModeratorCP: what staff did is for the +admins who manage that staff. + +## Retention + +Open **Logs → Retention settings** on either staff log and pick how long +entries are kept - one period for both logs: 30, 90, 180, 365 (the default) or 730 days, or forever. A daily cron +job, `audit-retention`, deletes older entries oldest first in batches of 5,000, +and records one **Deleted expired log entries** entry when it removed any. +No route deletes or edits an entry: the log is append-only. + +## Export + +**Export CSV** downloads up to 10,000 entries matching the current filters, +newest first. Values a spreadsheet would run as a formula (`=`, `+`, `-`, `@`) +are prefixed with `'`. The IP address column is only included with +`can_view_sensitive`, and the export itself is recorded. + +## Privacy + +Entries keep the minimum needed to answer "who did what": the actor's id and +name, the target's id and label, and bounded field changes. Email addresses, +birthdays and other personal fields changed by an admin are recorded as +changed, never with their values; secrets and passwords never. IP addresses are +stored for staff and member actions only, readable with `can_view_sensitive`, +and expire with the rest of the entry. diff --git a/apps/web/content/docs/dev/audit-logs/activity-logs-admin-list.webp b/apps/web/content/docs/dev/audit-logs/activity-logs-admin-list.webp new file mode 100644 index 0000000000..47c1fd4cab Binary files /dev/null and b/apps/web/content/docs/dev/audit-logs/activity-logs-admin-list.webp differ diff --git a/apps/web/content/docs/dev/audit-logs/activity-logs-detail-sheet.webp b/apps/web/content/docs/dev/audit-logs/activity-logs-detail-sheet.webp new file mode 100644 index 0000000000..a06ea88084 Binary files /dev/null and b/apps/web/content/docs/dev/audit-logs/activity-logs-detail-sheet.webp differ diff --git a/apps/web/content/docs/dev/audit-logs/index.mdx b/apps/web/content/docs/dev/audit-logs/index.mdx new file mode 100644 index 0000000000..3753918662 --- /dev/null +++ b/apps/web/content/docs/dev/audit-logs/index.mdx @@ -0,0 +1,74 @@ +--- +title: Activity Logs +description: VitNode's audit log answers who changed what, when and on which resource - across the AdminCP, moderators, plugins and Content Engine content. +icon: History +--- + +import { ImgDocs } from '@/components/fumadocs/img' + +import adminList from './activity-logs-admin-list.webp' +import detailSheet from './activity-logs-detail-sheet.webp' + +Activity Logs (audit logs) are VitNode's central record of administrative, +moderation, plugin and content operations. Every entry says who acted, where +(AdminCP, ModeratorCP, the site or the system), which plugin and action it was, +which resource it touched, and what changed. It lives with your staff, split in +two: + +- **AdminCP → Staff → Administrators → Logs** (`/admin/core/staff/admins/logs`) + shows AdminCP changes, plus what members, plugins and scheduled jobs did. +- **AdminCP → Staff → Moderators → Logs** (`/admin/core/staff/moderators/logs`) + shows what moderators did: reports, approvals and hidden content. + + + +Click an entry to open its details: the actor, the plugin, the target and each +changed field before and after. + + + +## What is recorded without any code + +| Area | Recorded automatically | +| ------------------- | ---------------------------------------------------------------------------------------------------------- | +| Content Engine | Create, edit, delete, duplicate, publish, unpublish, hide, unhide, restore, schedule and every translation change of every content type | +| Roles and staff | Roles created, edited and deleted; administrator and moderator permissions added, changed and removed | +| Users | Profile and role changes made in the AdminCP, password resets, email verification, signed-out devices | +| Settings | AI settings and access, notification types and danger-zone actions, report reasons, log retention | +| Moderation | Taking, assigning, resolving and reopening reports, notes, hiding reported content, approvals | +| Background work | Scheduled publishing and cron/queue writes, attributed to the system with who booked them | + +Your plugin's own operations - moving a forum topic, refunding an order - are +one call away. See [Record plugin events](/docs/dev/audit-logs/record-plugin-events). + +## Audit logs, revisions and technical logs + +VitNode keeps three kinds of history. They answer different questions, so none +replaces another. + +| History | Answers | Stored in | +| ---------------------------------------------------- | ---------------------------------------------------- | ------------------------- | +| Activity Logs | Who did what, when, to which resource | `core_audit_logs` | +| [Content revisions](/docs/content-engine/editorial) | What a record's content used to be, restorable | `core_content_revisions` | +| [Technical logs](/docs/dev/debugging/logging) | Warnings and errors for whoever debugs the server | `core_logs` | + +An audit entry of an editorial content type points at the revision it created +instead of copying the content, and the detail sheet shows the difference from +the previous revision. + +## Choose a guide + +| You want to | Read | +| ---------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| Record your plugin's own operations, with before/after changes | [Record plugin events](/docs/dev/audit-logs/record-plugin-events) | +| Understand or tune what the Content Engine records | [Content Engine activity logs](/docs/content-engine/activity-logs) | +| Decide who may read the log, how long it is kept, and export it | [Access and retention](/docs/dev/audit-logs/access-and-retention) | +| Look up the API, the stored fields and the limits | [Activity Logs reference](/docs/dev/audit-logs/reference) | diff --git a/apps/web/content/docs/dev/audit-logs/meta.json b/apps/web/content/docs/dev/audit-logs/meta.json new file mode 100644 index 0000000000..b31c86eb33 --- /dev/null +++ b/apps/web/content/docs/dev/audit-logs/meta.json @@ -0,0 +1,11 @@ +{ + "title": "Activity Logs", + "description": "Who changed what, when - one audit trail for admins, moderators, plugins and content", + "icon": "History", + "pages": [ + "index", + "record-plugin-events", + "access-and-retention", + "reference" + ] +} diff --git a/apps/web/content/docs/dev/audit-logs/record-plugin-events.mdx b/apps/web/content/docs/dev/audit-logs/record-plugin-events.mdx new file mode 100644 index 0000000000..874b93f5b8 --- /dev/null +++ b/apps/web/content/docs/dev/audit-logs/record-plugin-events.mdx @@ -0,0 +1,157 @@ +--- +title: Record plugin events +description: Record your plugin's own operations in the VitNode Activity Logs with c.get('audit').record(), inside the same transaction as the change, with before/after values and translated labels. +icon: PenLine +--- + +Content Engine writes are audited for you. For your plugin's own operations - +moving a forum topic, refunding an order, saving a plugin setting - call +`c.get('audit').record()` from the API route that performs the change. The +actor, IP address and request id come from the request; you describe the +operation. + +This guide moves a forum topic to another forum and records it. + + + + +### Record the operation in the same transaction + +Write the entry with the transaction of the change. Then a rolled-back move +leaves no entry, and a failed audit write fails the move instead of losing the +trail. + +```ts title="plugins/forum/src/api/modules/topics/routes/move.route.ts" +import { buildRoute } from '@/api/builders' +import { eq } from 'drizzle-orm' + +import { forum_topics } from '@/database/topics' + +export const moveTopicRoute = buildRoute({ + // route: { method: 'post', path: '/{id}/move', ... } + handler: async (c) => { + const { id } = c.req.valid('param') + const { forumId } = c.req.valid('json') + + await c.get('db').transaction(async (tx) => { + const [before] = await tx + .select() + .from(forum_topics) + .where(eq(forum_topics.id, id)) + const [after] = await tx + .update(forum_topics) + .set({ forumId }) + .where(eq(forum_topics.id, id)) + .returning() + + await c.get('audit').record( + { + action: 'topic.moved', + area: 'moderator', + target: { type: 'topic', id, label: after.title }, + changes: c.get('audit').diff({ before, after, include: ['forumId'] }), + scope: { family: 'forum', id: forumId }, + }, + { tx }, + ) + }) + + return c.json({ ok: true }) + }, +}) +``` + +`pluginId` defaults to the plugin of the route, so the entry is filed under +`@vitnode/forum`. `area` defaults to `admin` on an AdminCP request, `public` +for a signed-in user and `system` otherwise; pass `moderator` from moderation +tools, so the entry lands in the moderators' log. `scope` records which forum +the action was about. + + + + +### Describe the changes + +`diff()` (also exported as `auditDiff` from `@vitnode/core/lib/audit/diff`) +compares two objects and keeps only the fields that changed: + +```ts +c.get('audit').diff({ + before: { name: 'News', slug: 'news', viewCount: 10 }, + after: { name: 'Announcements', slug: 'announcements', viewCount: 11 }, + exclude: ['viewCount'], +}) +// { name: { from: 'News', to: 'Announcements' }, +// slug: { from: 'news', to: 'announcements' } } +``` + +Prefer `include` for anything security-related: an allowlist cannot leak a +column added later. Passwords, tokens, secrets, API keys, cookies and recovery +codes are always stored as `{ redacted: true }` - the entry says they changed, +never what to. Rich-text documents are stored as a marker; their content +belongs to [revisions](/docs/content-engine/editorial). + + + + +### Declare the action and its label + +Declare your actions so the Activity Logs action filter offers them, and give +them a label in your plugin's locale file. + +```ts title="plugins/forum/src/api/audit-actions.ts" +import { defineAuditActions } from '@vitnode/core/lib/audit/actions' + +export const forumAuditActions = defineAuditActions([ + { action: 'topic.moved', targetType: 'topic' }, + { action: 'topic.locked', targetType: 'topic' }, +]) +``` + +```ts title="plugins/forum/src/config.api.ts" +export const forumApiPlugin = () => + buildApiPlugin({ + manifest, + auditActions: forumAuditActions, // [!code ++] + modules: [/* ... */], + }) +``` + +```json title="plugins/forum/src/locales/en.json" +{ + "@vitnode/forum": { + "audit": { + "actions": { + "topic": { "moved": "Moved topic", "locked": "Locked topic" } + } + } + } +} +``` + +The label key is `{pluginId}.audit.actions.{action}`, with the dots of the +action nesting. Without a label the UI shows the action id made readable +("Topic moved"), and an undeclared action is still recorded and listed. + + + + +## Check the result + +Move a topic, then open **AdminCP → Staff → Moderators → Logs**. The newest +entry reads **Moved topic**, with the topic's title as the target. Click it: +the sheet shows **Forum** going from the old id to the new one. An entry +recorded with any other `area` lands in **Administrators → Logs** instead. + +## Background work and failures + +- In a cron job or queue task, entries are attributed to the **system** + automatically. To add context, wrap the work in `withAuditContext` from + `@vitnode/core/api/lib/audit/context`, for example + `{ metadata: { trigger: 'import' } }`. +- A malformed action or target type throws `AuditLogError` - it is a bug, so + it is never swallowed. For a non-critical operation outside a transaction, + pass `{ bestEffort: true }` to log a failed write to the technical log + instead of failing the request. + +See the [reference](/docs/dev/audit-logs/reference) for every option. diff --git a/apps/web/content/docs/dev/audit-logs/reference.mdx b/apps/web/content/docs/dev/audit-logs/reference.mdx new file mode 100644 index 0000000000..ab10447a3b --- /dev/null +++ b/apps/web/content/docs/dev/audit-logs/reference.mdx @@ -0,0 +1,93 @@ +--- +title: Activity Logs reference +description: Reference for c.get('audit').record(), auditDiff, withAuditContext, defineAuditActions, the core_audit_logs fields, the Activity Logs API routes and their limits. +icon: BookOpen +--- + +Every server-side Activity Logs API, the stored fields and the limits, as +implemented in `@vitnode/core`. + +## `c.get('audit').record(input, options?)` + +Writes one entry. Server-only. Returns the entry id, or `null` when an +enclosing operation folded it into its own entry or a best-effort write failed. + +| Name | Type | Required | Default | Description | +| ---------- | ----------------------------------------- | -------- | ----------------------------------------- | ----------------------------------------------------------------------- | +| `action` | `string` | Yes | | Lowercase words joined by dots, at most 150 characters: `topic.moved` | +| `target` | `{ type, id?, label? }` | Yes | | `type` like `topic` or `blog.category`; `id` a number or string; `label` kept after deletion | +| `area` | `'admin' \| 'moderator' \| 'public' \| 'system'` | No | From the session | `admin` with an AdminCP session, `public` for a user, else `system` | +| `changes` | `Record` | No | `null` | Usually from `diff()`; sanitized and capped at 50 fields / 16 KB | +| `metadata` | `Record` | No | `{}` | Small facts; sanitized and capped at 16 KB | +| `pluginId` | `string` | No | The route's plugin | Which plugin the entry belongs to | +| `scope` | `{ family, id }` | No | `null` | The moderation scope the action was about | + +| Option | Type | Default | Description | +| ------------ | --------- | ------- | ----------------------------------------------------------------------------- | +| `tx` | Drizzle transaction | | Write with the change's transaction: commit and roll back together | +| `bestEffort` | `boolean` | `false` | Without `tx`: log a database failure to `core_logs` instead of throwing | + +The actor, the actor's name, the IP address and the request id are always taken +from the request, never from `input`. Malformed input throws `AuditLogError`. + +## `auditDiff({ before, after, include?, exclude? })` + +From `@vitnode/core/lib/audit/diff`, also `c.get('audit').diff()`. Universal. +Returns only the fields whose value changed, or `null`. A missing field counts +as `null`; dates compare by instant; objects and arrays by value; rich-text +documents become `"[rich text]"`; sensitive field names become +`{ redacted: true }`. + +## `withAuditContext(c, frame, body)` + +From `@vitnode/core/api/lib/audit/context`. Applies trusted context to every +entry `body` writes on `c`. Frames nest; the innermost wins. + +| Frame field | Description | +| --------------- | --------------------------------------------------------------------------- | +| `actor` | Attribute entries to this actor - `AUDIT_SYSTEM_ACTOR` for background work | +| `area` | Area for entries that do not name one | +| `correlationId` | Group the entries; defaults to one id per request | +| `metadata` | Merged into each entry's metadata | +| `scope` | Moderation scope for entries that do not name one | +| `intercept` | Rewrite or drop (`null`) each entry before it is written | + +Cron jobs and queue tasks already run inside a system frame. + +## `defineAuditActions(actions)` + +From `@vitnode/core/lib/audit/actions`. Validates `{ action, targetType }[]` +for `buildApiPlugin({ auditActions })`. Labels come from +`{pluginId}.audit.actions.{action}` in the plugin's messages. + +## Stored fields (`core_audit_logs`) + +| Column | Notes | +| ----------------------------- | --------------------------------------------------------------------- | +| `pluginId`, `action`, `area` | Plain text: readable after the plugin is removed | +| `actorType`, `actorId`, `actorName` | `staff`, `api` or `system`; `actorId` is set to `null` when the account is deleted, `actorName` stays | +| `targetType`, `targetId`, `targetLabel` | No foreign key: entries outlive their target | +| `changes`, `metadata` | Sanitized JSON | +| `contentTypeId`, `revisionId` | Content Engine entries | +| `scopeFamily`, `scopeId` | Moderation scope | +| `ipAddress`, `correlationId` | Sensitive; `ipAddress` is `null` for system entries | + +Indexed for newest-first paging, actor, plugin, action, area, one target's +history and scope, each with `createdAt`. + +## API routes + +| Route | Permission | Returns | +| --------------------------------------- | --------------------------------------------------------- | ---------------------------------------- | +| `GET /admin/activity-logs` | Admin `activity_logs:can_view` | Keyset page, newest first | +| `GET /admin/activity-logs/{id}` | Admin `can_view` | Entry with changes, revision diff, link | +| `GET /admin/activity-logs/entity` | Admin `can_view`, or `can_view` on the content type | One target's history with changes | +| `GET /admin/activity-logs/filters` | Admin `can_view` | Declared actions and installed plugins | +| `GET /admin/activity-logs/export` | Admin `can_export` | CSV, at most 10,000 rows | +| `GET`/`PUT /admin/activity-logs/settings` | `can_view` / `can_manage_retention` | `{ retentionDays }` | + +List filters: `staff` (`moderator` keeps the moderator area, `admin` every +other area), `pluginId`, `area`, `actorId`, `action`, `targetType`, `targetId`, +`from` and `to` (`YYYY-MM-DD`, UTC, inclusive), `search` (target label, action +and actor name), `cursor`, `first` (1-100, default 25). The export takes the +same filters. diff --git a/apps/web/content/docs/dev/meta.json b/apps/web/content/docs/dev/meta.json index 99c9de4aef..69f85a57e6 100644 --- a/apps/web/content/docs/dev/meta.json +++ b/apps/web/content/docs/dev/meta.json @@ -31,6 +31,7 @@ "widgets", "working-with-users", "moderation", + "audit-logs", "events", "notifications", "---Services---", diff --git a/apps/web/src/page-cache.gen.ts b/apps/web/src/page-cache.gen.ts index db33dcf2f3..cdb7b2ba5a 100644 --- a/apps/web/src/page-cache.gen.ts +++ b/apps/web/src/page-cache.gen.ts @@ -22,7 +22,9 @@ export const pageCacheRoutes: PageCacheRouteTable = [ { path: '/admin/core/staff/admins/edit/:id' }, { path: '/admin/core/staff/moderators/edit/:id' }, { path: '/admin/core/staff/admins/create' }, + { path: '/admin/core/staff/admins/logs' }, { path: '/admin/core/staff/moderators/create' }, + { path: '/admin/core/staff/moderators/logs' }, { path: '/admin/core/ai/actions' }, { path: '/admin/core/debug/ai' }, { path: '/admin/core/debug/cache' }, diff --git a/packages/vitnode/src/api/admin-staff-permission.test.ts b/packages/vitnode/src/api/admin-staff-permission.test.ts index 7c0bcb04f1..a1ebe55902 100644 --- a/packages/vitnode/src/api/admin-staff-permission.test.ts +++ b/packages/vitnode/src/api/admin-staff-permission.test.ts @@ -7,6 +7,8 @@ import { newBuildPluginApiCore } from "./plugin"; const ALLOWLIST = [ "GET /admin/session", + // Activity Logs `can_view`, or `can_view` on the content type - see the route. + "GET /admin/activity-logs/entity", "GET /admin/staff/permission-catalog", "GET /admin/roles/list", "POST /admin/staff/entry/:type", diff --git a/packages/vitnode/src/api/lib/ai/settings-store.ts b/packages/vitnode/src/api/lib/ai/settings-store.ts index cd1d1c7c07..0ae19944c4 100644 --- a/packages/vitnode/src/api/lib/ai/settings-store.ts +++ b/packages/vitnode/src/api/lib/ai/settings-store.ts @@ -5,7 +5,7 @@ import { core_ai_settings } from "../../../database/ai"; export type AiSettingsRow = typeof core_ai_settings.$inferInsert; export const saveAiSettings = async ( - db: AiDatabase, + db: Omit, values: Omit, ): Promise => { await db diff --git a/packages/vitnode/src/api/lib/audit/access.ts b/packages/vitnode/src/api/lib/audit/access.ts new file mode 100644 index 0000000000..3d643aa5cc --- /dev/null +++ b/packages/vitnode/src/api/lib/audit/access.ts @@ -0,0 +1,26 @@ +import type { Context } from "hono"; + +import { AUDIT_CORE_PLUGIN_ID } from "../../../lib/audit/const"; +import { checkStaffPermission } from "../check-staff-permission"; + +/** The staff permission module of the Activity Logs. */ +export const AUDIT_PERMISSION_MODULE = "activity_logs"; + +export const AUDIT_PERMISSIONS = { + export: "can_export", + manageRetention: "can_manage_retention", + view: "can_view", + viewSensitive: "can_view_sensitive", +} as const; + +/** Whether the AdminCP admin holds one Activity Logs permission. */ +export const canAdminAudit = async ( + c: Context, + permission: (typeof AUDIT_PERMISSIONS)[keyof typeof AUDIT_PERMISSIONS], +): Promise => + await checkStaffPermission(c, { + module: AUDIT_PERMISSION_MODULE, + permission, + plugin: AUDIT_CORE_PLUGIN_ID, + type: "admin", + }); diff --git a/packages/vitnode/src/api/lib/audit/context.ts b/packages/vitnode/src/api/lib/audit/context.ts new file mode 100644 index 0000000000..772553ef09 --- /dev/null +++ b/packages/vitnode/src/api/lib/audit/context.ts @@ -0,0 +1,118 @@ +import type { Context } from "hono"; + +import { randomUUID } from "node:crypto"; + +import type { AuditActorType, AuditArea } from "@/lib/audit/const"; + +/** Who an audit entry is attributed to. `userId` is `null` for the system. */ +export interface AuditActor { + type: AuditActorType; + userId: null | number; +} + +/** The system itself: a scheduled task, a queue job, a cron run. */ +export const AUDIT_SYSTEM_ACTOR: AuditActor = { type: "system", userId: null }; + +/** + * One audit entry as it travels through {@link AuditContextFrame.intercept}, + * before it is sanitized and written. + */ +export interface AuditPendingEntry { + action: string; + actor: AuditActor; + area: AuditArea; + changes: null | Record< + string, + { from?: unknown; redacted?: true; to?: unknown } + >; + contentTypeId: null | string; + metadata: Record; + pluginId: string; + revisionId: null | number; + scope: null | { family: string; id: string }; + target: { id: null | string; label: null | string; type: string }; +} + +/** + * Trusted context layered over a block of server code with + * {@link withAuditContext}. Never built from request input. + */ +export interface AuditContextFrame { + /** + * Attributes every entry in the block to this actor instead of the request's + * user - `AUDIT_SYSTEM_ACTOR` for background work. + */ + actor?: AuditActor; + area?: AuditArea; + /** Groups the block's entries. Defaults to one id per request. */ + correlationId?: string; + /** + * Sees each entry written inside the block, before it is stored, and returns + * the entry to write - changed or not - or `null` to drop it because the + * caller records the operation as a whole. Runs inside the same transaction. + */ + intercept?: (entry: AuditPendingEntry) => AuditPendingEntry | null; + /** Merged into every entry's metadata; the entry's own keys win. */ + metadata?: Record; + /** + * The moderation scope of every entry in the block that names none - a + * report action hiding a forum post belongs to the forum's scope. + */ + scope?: null | { family: string; id: string }; +} + +const frames = new WeakMap(); +const correlationIds = new WeakMap(); + +/** + * Runs `body` with `frame` applied to every audit entry it writes on `c` - + * the trusted way to attribute background work to the system, to say which + * schedule triggered a publish, or to fold nested writes into one entry. + * Frames nest; the innermost one is applied first. Server-only. + * + * @example + * ```ts + * import { AUDIT_SYSTEM_ACTOR, withAuditContext } from "@vitnode/core/api/lib/audit/context"; + * + * await withAuditContext( + * c, + * { actor: AUDIT_SYSTEM_ACTOR, area: "system", metadata: { trigger: "import" } }, + * async () => await importProducts(c), + * ); + * ``` + */ +export const withAuditContext = async ( + c: Context, + frame: AuditContextFrame, + body: () => Promise, +): Promise => { + const stack = frames.get(c) ?? []; + stack.push(frame); + frames.set(c, stack); + + try { + return await body(); + } finally { + const index = stack.lastIndexOf(frame); + if (index !== -1) stack.splice(index, 1); + } +}; + +/** The frames active on `c`, innermost first. */ +export const auditContextFrames = (c: Context): AuditContextFrame[] => + [...(frames.get(c) ?? [])].reverse(); + +/** The id every entry written for this request (or background run) shares. */ +export const auditCorrelationId = (c: Context): string => { + for (const frame of auditContextFrames(c)) { + if (frame.correlationId) return frame.correlationId; + } + + const existing = correlationIds.get(c); + if (existing) return existing; + + const id = randomUUID(); + correlationIds.set(c, id); + + return id; +}; diff --git a/packages/vitnode/src/api/lib/audit/detail.ts b/packages/vitnode/src/api/lib/audit/detail.ts new file mode 100644 index 0000000000..b8720b27aa --- /dev/null +++ b/packages/vitnode/src/api/lib/audit/detail.ts @@ -0,0 +1,181 @@ +import type { Context } from "hono"; + +import { and, desc, eq, isNull, lt } from "drizzle-orm"; + +import type { ContentAnyRevisionSnapshot } from "@/content/revisions"; + +import { contentIdFromKey } from "@/content/ids"; +import { contentAdminHref, contentEditHref } from "@/content/registry"; +import { contentDefinitionOf, findContentModel } from "@/content/server/model"; +import { core_content_revisions } from "@/database/content"; +import { sanitizeAuditValue } from "@/lib/audit/sanitize"; + +import type { AuditDetail, findAuditLog } from "./query"; + +import { checkStaffPermission } from "../check-staff-permission"; + +type FoundEntry = NonNullable>>; + +const sameValue = (before: unknown, after: unknown): boolean => + JSON.stringify(before ?? null) === JSON.stringify(after ?? null); + +const canAdminView = async ( + c: Context, + plugin: string, + module: string, +): Promise => + await checkStaffPermission(c, { + module, + permission: "can_view", + plugin, + type: "admin", + }); + +/** + * The revision a content entry points at, diffed against the one before it - + * read from the Content Revisions the write already stored, never copied into + * the audit entry. `available: false` when the revision has been pruned, the + * content type is gone, or the viewer may not read the content type. + */ +const revisionOf = async ( + c: Context, + entry: FoundEntry, +): Promise => { + if (!entry.contentTypeId || entry.revisionId === null) return null; + const unavailable = { + available: false, + fields: [], + operation: null, + version: null, + }; + + const registered = findContentModel( + c.get("core").contentModels, + entry.contentTypeId, + ); + if (!registered) return unavailable; + const definition = contentDefinitionOf(registered.model); + if ( + !(await canAdminView(c, registered.pluginId, definition.permissionModule)) + ) { + return unavailable; + } + + const db = c.get("db"); + const [revision] = await db + .select() + .from(core_content_revisions) + .where( + and( + eq(core_content_revisions.id, entry.revisionId), + eq(core_content_revisions.contentTypeId, definition.id), + ), + ) + .limit(1); + if (!revision) return unavailable; + + const [previous] = await db + .select({ snapshot: core_content_revisions.snapshot }) + .from(core_content_revisions) + .where( + and( + eq(core_content_revisions.contentTypeId, definition.id), + eq(core_content_revisions.itemId, revision.itemId), + revision.languageId === null + ? isNull(core_content_revisions.languageId) + : eq(core_content_revisions.languageId, revision.languageId), + lt(core_content_revisions.version, revision.version), + ), + ) + .orderBy(desc(core_content_revisions.version)) + .limit(1); + + const after = revision.snapshot.fields; + const before = (previous?.snapshot as ContentAnyRevisionSnapshot | undefined) + ?.fields; + const excluded = new Set(definition.audit.excludeFields); + const names = [ + ...new Set(revision.changedFields.map(path => path.split(".")[0])), + ].filter(name => !excluded.has(name)); + + return { + available: true, + fields: names + .filter( + name => before === undefined || !sameValue(before[name], after[name]), + ) + .map(name => ({ + // Bounded like every audit value: a rich-text document is a marker + // here, and the revision history panel is where it is read in full. + after: sanitizeAuditValue(after[name]) ?? null, + before: + before === undefined + ? null + : (sanitizeAuditValue(before[name]) ?? null), + name, + })), + operation: revision.operation, + version: revision.version, + }; +}; + +/** + * Where the target lives in the AdminCP, when the viewer may open it: the user + * or the content record. `null` for anything else - a deleted target answers 404 there + * anyway, and no plugin code is run to work a path out. + */ +const targetHrefOf = async ( + c: Context, + entry: FoundEntry, +): Promise => { + const { id, type } = entry.target; + if (id === null) return null; + + if (type === "user") { + return (await canAdminView(c, "@vitnode/core", "users")) + ? `/admin/core/users/${id}` + : null; + } + + if (entry.contentTypeId && type === entry.contentTypeId) { + const registered = findContentModel( + c.get("core").contentModels, + entry.contentTypeId, + ); + if (!registered) return null; + const definition = contentDefinitionOf(registered.model); + if ( + !(await canAdminView(c, registered.pluginId, definition.permissionModule)) + ) { + return null; + } + const itemId = contentIdFromKey(definition.idStrategy, id); + if ( + itemId !== null && + definition.admin.edit.mode === "page" && + entry.action !== "content.deleted" + ) { + return contentEditHref(definition, itemId); + } + + return contentAdminHref(definition); + } + + return null; +}; + +/** + * One entry as the detail sheet shows it. Sensitive technical metadata (IP + * address, correlation id) only with `canViewSensitive`. + */ +export const auditDetailOf = async ( + c: Context, + entry: FoundEntry, + { canViewSensitive }: { canViewSensitive: boolean }, +): Promise => ({ + ...entry, + correlationId: canViewSensitive ? entry.correlationId : null, + ipAddress: canViewSensitive ? entry.ipAddress : null, + revision: await revisionOf(c, entry), + targetHref: await targetHrefOf(c, entry), +}); diff --git a/packages/vitnode/src/api/lib/audit/labels.ts b/packages/vitnode/src/api/lib/audit/labels.ts new file mode 100644 index 0000000000..73a5e6c23d --- /dev/null +++ b/packages/vitnode/src/api/lib/audit/labels.ts @@ -0,0 +1,102 @@ +import type { Context } from "hono"; + +import { eq } from "drizzle-orm"; + +import type { MultiLangValue } from "@/lib/helpers/multi-lang"; + +import { core_users } from "@/database/users"; + +import type { PermissionsStaffArgs } from "../permission-staff"; + +import { resolveRoleNames } from "../resolve-role-names"; + +/** + * One readable label out of a multi-language value: the default language's, + * else English, else the first one stored. `null` when there is none. + */ +export const multiLangAuditLabel = ( + values: MultiLangValue | null | undefined, + defaultLocale?: string, +): null | string => { + if (!values || values.length === 0) return null; + const pick = + values.find(entry => entry.languageCode === defaultLocale) ?? + values.find(entry => entry.languageCode === "en") ?? + values[0]; + + return pick.value.trim() === "" ? null : pick.value; +}; + +/** A role's name as an audit label, read from its stored translations. */ +export const roleAuditLabel = async ( + c: Context, + roleId: number, +): Promise => { + const names = (await resolveRoleNames(c, [roleId])).get(roleId) ?? []; + + return multiLangAuditLabel( + names.map(entry => ({ + languageCode: entry.languageCode, + value: entry.name, + })), + c.get("core")?.i18n?.defaultLocale, + ); +}; + +/** A user's display name as an audit label. */ +export const userAuditLabel = async ( + c: Context, + userId: number, +): Promise => { + const [row] = await c + .get("db") + .select({ name: core_users.name }) + .from(core_users) + .where(eq(core_users.id, userId)) + .limit(1); + + return row?.name ?? null; +}; + +/** Whom a staff permission entry governs - a role or one user - as a label. */ +export const staffEntryAuditLabel = async ( + c: Context, + entry: { + roleId: null | number | undefined; + userId: null | number | undefined; + }, +): Promise => { + if (typeof entry.roleId === "number") + return await roleAuditLabel(c, entry.roleId); + if (typeof entry.userId === "number") + return await userAuditLabel(c, entry.userId); + + return null; +}; + +/** One staff grant as a stable string: `@vitnode/core:users:can_edit[forum:1,2]`. */ +export const staffGrantAuditKey = (grant: PermissionsStaffArgs): string => { + const base = `${grant.plugin}:${grant.module}:${grant.permission}`; + if (!grant.scope) return base; + + return grant.scope.mode === "all" + ? `${base}[${grant.scope.family}:*]` + : `${base}[${grant.scope.family}:${[...grant.scope.ids].sort().join(",")}]`; +}; + +/** + * What changed between two grant lists, as audit metadata: the grants added and + * the grants removed, never the full lists. + */ +export const staffGrantsAuditDelta = ( + before: readonly PermissionsStaffArgs[], + after: readonly PermissionsStaffArgs[], +): { granted: string[]; revoked: string[] } => { + const from = new Set(before.map(staffGrantAuditKey)); + const to = new Set(after.map(staffGrantAuditKey)); + + return { + granted: [...to].filter(key => !from.has(key)).sort(), + revoked: [...from].filter(key => !to.has(key)).sort(), + }; +}; diff --git a/packages/vitnode/src/api/lib/audit/query.ts b/packages/vitnode/src/api/lib/audit/query.ts new file mode 100644 index 0000000000..fa7ac46e20 --- /dev/null +++ b/packages/vitnode/src/api/lib/audit/query.ts @@ -0,0 +1,465 @@ +import type { SQL } from "drizzle-orm"; +import type { Context } from "hono"; + +import { z } from "@hono/zod-openapi"; +import { and, desc, eq, gte, ilike, lt, ne, or, sql } from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; + +import type { AuditChanges } from "@/lib/audit/const"; + +import { core_audit_logs } from "@/database/audit"; +import { core_roles } from "@/database/roles"; +import { core_users } from "@/database/users"; +import { containsLikePattern } from "@/lib/api/like-pattern"; +import { + AUDIT_ACTION_MAX_LENGTH, + AUDIT_ACTOR_TYPES, + AUDIT_AREAS, + AUDIT_PLUGIN_ID_MAX_LENGTH, + AUDIT_STAFF_LOGS, + AUDIT_TARGET_ID_MAX_LENGTH, + AUDIT_TARGET_TYPE_MAX_LENGTH, +} from "@/lib/audit/const"; + +export const AUDIT_DEFAULT_PAGE_SIZE = 25; +export const AUDIT_MAX_PAGE_SIZE = 100; +const AUDIT_SEARCH_MAX_LENGTH = 100; + +const day = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, "A date as YYYY-MM-DD.") + .openapi({ example: "2026-10-24" }); + +/** The filters every audit list understands, as query parameters. */ +export const zodAuditFilters = z.object({ + action: z.string().max(AUDIT_ACTION_MAX_LENGTH).optional(), + actorId: z.coerce.number().int().positive().optional(), + area: z.enum(AUDIT_AREAS).optional(), + /** First day included, in UTC. */ + from: day.optional(), + pluginId: z.string().max(AUDIT_PLUGIN_ID_MAX_LENGTH).optional(), + /** Matches the target's label, the action and the actor's name. */ + search: z.string().trim().max(AUDIT_SEARCH_MAX_LENGTH).optional(), + targetId: z.string().max(AUDIT_TARGET_ID_MAX_LENGTH).optional(), + targetType: z.string().max(AUDIT_TARGET_TYPE_MAX_LENGTH).optional(), + /** Last day included, in UTC. */ + to: day.optional(), + /** + * Which staff log: `moderator` keeps the moderator area, `admin` everything + * else - AdminCP, member and system activity. + */ + staff: z.enum(AUDIT_STAFF_LOGS).optional(), +}); + +export type AuditFilters = z.infer; + +export const zodAuditPageQuery = zodAuditFilters.extend({ + cursor: z.string().max(200).optional(), + first: z.coerce.number().int().min(1).max(AUDIT_MAX_PAGE_SIZE).optional(), +}); + +const zodAuditActor = z + .object({ + id: z.number(), + name: z.string(), + nameCode: z.string(), + roleColor: z.string().nullable(), + rolePrefix: z.string().nullable(), + }) + .nullable(); + +/** One entry as a list shows it - no changes, no metadata. */ +export const zodAuditListEntry = z.object({ + action: z.string(), + /** The account as it is now, or `null` for the system or a deleted account. */ + actor: zodAuditActor, + /** The name the entry was written under, kept after the account is gone. */ + actorName: z.string().nullable(), + actorType: z.enum(AUDIT_ACTOR_TYPES), + area: z.enum(AUDIT_AREAS), + contentTypeId: z.string().nullable(), + createdAt: z.date(), + id: z.number(), + pluginId: z.string(), + revisionId: z.number().nullable(), + scope: z.object({ family: z.string(), id: z.string() }).nullable(), + target: z.object({ + id: z.string().nullable(), + label: z.string().nullable(), + type: z.string(), + }), +}); + +export type AuditListEntry = z.infer; + +export const zodAuditPage = z.object({ + edges: z.array(zodAuditListEntry), + pageInfo: z.object({ + endCursor: z.string().nullable(), + hasNextPage: z.boolean(), + }), +}); + +/** One resource's history: list entries plus their recorded changes and metadata. */ +export const zodAuditEntityPage = z.object({ + edges: z.array( + zodAuditListEntry.extend({ + changes: z + .record( + z.string(), + z.object({ + from: z.unknown().optional(), + redacted: z.literal(true).optional(), + to: z.unknown().optional(), + }), + ) + .nullable(), + metadata: z.record(z.string(), z.unknown()), + }), + ), + pageInfo: z.object({ + endCursor: z.string().nullable(), + hasNextPage: z.boolean(), + }), +}); + +/** One entry in full, as the detail sheet shows it. */ +export const zodAuditDetail = zodAuditListEntry.extend({ + changes: z + .record( + z.string(), + z.object({ + from: z.unknown().optional(), + redacted: z.literal(true).optional(), + to: z.unknown().optional(), + }), + ) + .nullable(), + /** Needs `can_view_sensitive`; `null` otherwise. */ + correlationId: z.string().nullable(), + /** Needs `can_view_sensitive`; `null` otherwise. */ + ipAddress: z.string().nullable(), + metadata: z.record(z.string(), z.unknown()), + /** The content revision the entry points at, when it still exists and the viewer may read it. */ + revision: z + .object({ + available: z.boolean(), + fields: z.array( + z.object({ + after: z.unknown(), + before: z.unknown(), + name: z.string(), + }), + ), + operation: z.string().nullable(), + version: z.number().nullable(), + }) + .nullable(), + /** An AdminCP path to the target, only when the viewer may open it. */ + targetHref: z.string().nullable(), +}); + +export type AuditDetail = z.infer; + +const startOfDay = (value: string): Date => new Date(`${value}T00:00:00.000Z`); + +/** The SQL of the filters, ANDed. Never trusted for authorization - see the access conditions. */ +export const auditFilterConditions = (filters: AuditFilters): SQL[] => { + const conditions: (SQL | undefined)[] = [ + filters.action ? eq(core_audit_logs.action, filters.action) : undefined, + filters.actorId ? eq(core_audit_logs.actorId, filters.actorId) : undefined, + filters.area ? eq(core_audit_logs.area, filters.area) : undefined, + filters.staff === "moderator" + ? eq(core_audit_logs.area, "moderator") + : undefined, + filters.staff === "admin" + ? ne(core_audit_logs.area, "moderator") + : undefined, + filters.pluginId + ? eq(core_audit_logs.pluginId, filters.pluginId) + : undefined, + filters.targetType + ? eq(core_audit_logs.targetType, filters.targetType) + : undefined, + filters.targetId + ? eq(core_audit_logs.targetId, filters.targetId) + : undefined, + filters.from + ? gte(core_audit_logs.createdAt, startOfDay(filters.from)) + : undefined, + filters.to + ? lt( + core_audit_logs.createdAt, + new Date(startOfDay(filters.to).getTime() + 86_400_000), + ) + : undefined, + ]; + + const term = filters.search?.trim(); + if (term) { + const pattern = containsLikePattern(term); + conditions.push( + or( + ilike(core_audit_logs.targetLabel, pattern), + ilike(core_audit_logs.action, pattern), + ilike(core_audit_logs.actorName, pattern), + ), + ); + } + + return conditions.filter((condition): condition is SQL => !!condition); +}; + +const encodeCursor = (createdAt: Date, id: number): string => + Buffer.from(JSON.stringify([createdAt.toISOString(), id])).toString( + "base64url", + ); + +const decodeCursor = (cursor: string): { createdAt: Date; id: number } => { + try { + const [createdAt, id] = JSON.parse( + Buffer.from(cursor, "base64url").toString("utf8"), + ) as [unknown, unknown]; + const date = new Date(String(createdAt)); + if ( + typeof id === "number" && + Number.isSafeInteger(id) && + !Number.isNaN(date.getTime()) + ) { + return { createdAt: date, id }; + } + } catch { + // Reported below, as every other malformed cursor is. + } + + throw new HTTPException(400, { message: "Invalid cursor." }); +}; + +const listSelection = { + action: core_audit_logs.action, + actorId: core_audit_logs.actorId, + actorName: core_audit_logs.actorName, + actorType: core_audit_logs.actorType, + area: core_audit_logs.area, + contentTypeId: core_audit_logs.contentTypeId, + createdAt: core_audit_logs.createdAt, + currentName: core_users.name, + id: core_audit_logs.id, + nameCode: core_users.nameCode, + pluginId: core_audit_logs.pluginId, + revisionId: core_audit_logs.revisionId, + roleColor: core_roles.color, + rolePrefix: core_roles.prefix, + scopeFamily: core_audit_logs.scopeFamily, + scopeId: core_audit_logs.scopeId, + targetId: core_audit_logs.targetId, + targetLabel: core_audit_logs.targetLabel, + targetType: core_audit_logs.targetType, +}; + +type ListRow = { + [K in keyof typeof listSelection]: + | (typeof listSelection)[K]["_"]["data"] + | null; +}; + +const toListEntry = (row: ListRow): AuditListEntry => ({ + action: row.action ?? "", + actor: + row.actorId !== null && row.currentName !== null + ? { + id: row.actorId, + name: row.currentName, + nameCode: row.nameCode ?? "", + roleColor: row.roleColor, + rolePrefix: row.rolePrefix, + } + : null, + actorName: row.actorName, + actorType: row.actorType ?? "system", + area: row.area ?? "system", + contentTypeId: row.contentTypeId, + createdAt: row.createdAt ?? new Date(0), + id: row.id ?? 0, + pluginId: row.pluginId ?? "", + revisionId: row.revisionId, + scope: + row.scopeFamily && row.scopeId + ? { family: row.scopeFamily, id: row.scopeId } + : null, + target: { + id: row.targetId, + label: row.targetLabel, + type: row.targetType ?? "", + }, +}); + +/** + * One page of entries, newest first, by keyset on `(createdAt, id)` - the + * index the table leads with - so a deep page costs what the first one does + * and nothing ever counts the whole table. `access` is the viewer's + * authorization condition and is applied before anything is read. + */ +export const listAuditLogs = async ( + c: Context, + { + access, + cursor, + filters, + first = AUDIT_DEFAULT_PAGE_SIZE, + }: { + access: SQL | undefined; + cursor?: string; + filters: AuditFilters; + first?: number; + }, +): Promise> => { + const { edges, pageInfo } = await listAuditLogsWithDetails(c, { + access, + cursor, + filters, + first, + }); + + return { + edges: edges.map( + ({ changes: _changes, metadata: _metadata, ...entry }) => entry, + ), + pageInfo, + }; +}; + +/** + * {@link listAuditLogs}, with each entry's changes and metadata - already + * sanitized and bounded when written, so a short history can show them + * inline. Never the IP address or the correlation id. + */ +export const listAuditLogsWithDetails = async ( + c: Context, + { + access, + cursor, + filters, + first = AUDIT_DEFAULT_PAGE_SIZE, + }: { + access: SQL | undefined; + cursor?: string; + filters: AuditFilters; + first?: number; + }, +): Promise> => { + const conditions = [access, ...auditFilterConditions(filters)]; + if (cursor) { + const after = decodeCursor(cursor); + conditions.push( + sql`(${core_audit_logs.createdAt}, ${core_audit_logs.id}) < (${after.createdAt}, ${after.id})`, + ); + } + + const rows = await c + .get("db") + .select({ + ...listSelection, + changes: core_audit_logs.changes, + metadata: core_audit_logs.metadata, + }) + .from(core_audit_logs) + .leftJoin(core_users, eq(core_users.id, core_audit_logs.actorId)) + .leftJoin(core_roles, eq(core_roles.id, core_users.roleId)) + .where(and(...conditions)) + .orderBy(desc(core_audit_logs.createdAt), desc(core_audit_logs.id)) + .limit(first + 1); + + const page = rows.slice(0, first); + const last = page.at(-1); + + return { + edges: page.map(row => ({ + ...toListEntry(row as ListRow), + changes: row.changes, + metadata: row.metadata, + })), + pageInfo: { + endCursor: + last?.createdAt && last.id + ? encodeCursor(last.createdAt, last.id) + : null, + hasNextPage: rows.length > first, + }, + }; +}; + +/** Rows for an export, newest first, bounded by `limit`. */ +export const exportAuditLogs = async ( + c: Context, + { + access, + filters, + limit, + }: { access: SQL | undefined; filters: AuditFilters; limit: number }, +) => + await c + .get("db") + .select({ + action: core_audit_logs.action, + actorId: core_audit_logs.actorId, + actorName: core_audit_logs.actorName, + actorType: core_audit_logs.actorType, + area: core_audit_logs.area, + changes: core_audit_logs.changes, + createdAt: core_audit_logs.createdAt, + id: core_audit_logs.id, + ipAddress: core_audit_logs.ipAddress, + pluginId: core_audit_logs.pluginId, + scopeFamily: core_audit_logs.scopeFamily, + scopeId: core_audit_logs.scopeId, + targetId: core_audit_logs.targetId, + targetLabel: core_audit_logs.targetLabel, + targetType: core_audit_logs.targetType, + }) + .from(core_audit_logs) + .where(and(access, ...auditFilterConditions(filters))) + .orderBy(desc(core_audit_logs.createdAt), desc(core_audit_logs.id)) + .limit(limit); + +/** + * One entry with its changes and metadata, or `null` when it does not exist + * **or** `access` excludes it - the two are indistinguishable on purpose, so + * an id from another scope reveals nothing. + */ +export const findAuditLog = async ( + c: Context, + { access, id }: { access: SQL | undefined; id: number }, +): Promise< + | (AuditListEntry & { + changes: AuditChanges | null; + correlationId: null | string; + ipAddress: null | string; + metadata: Record; + }) + | null +> => { + const [row] = await c + .get("db") + .select({ + ...listSelection, + changes: core_audit_logs.changes, + correlationId: core_audit_logs.correlationId, + ipAddress: core_audit_logs.ipAddress, + metadata: core_audit_logs.metadata, + }) + .from(core_audit_logs) + .leftJoin(core_users, eq(core_users.id, core_audit_logs.actorId)) + .leftJoin(core_roles, eq(core_roles.id, core_users.roleId)) + .where(and(access, eq(core_audit_logs.id, id))) + .limit(1); + if (!row) return null; + + return { + ...toListEntry(row), + changes: row.changes, + correlationId: row.correlationId, + ipAddress: row.ipAddress, + metadata: row.metadata, + }; +}; diff --git a/packages/vitnode/src/api/lib/audit/registry.ts b/packages/vitnode/src/api/lib/audit/registry.ts new file mode 100644 index 0000000000..c882cfd70a --- /dev/null +++ b/packages/vitnode/src/api/lib/audit/registry.ts @@ -0,0 +1,92 @@ +import { z } from "@hono/zod-openapi"; + +import type { AuditActionDefinition } from "@/lib/audit/actions"; + +import { CONTENT_AUDIT_ACTIONS, CORE_AUDIT_ACTIONS } from "@/lib/audit/actions"; +import { AUDIT_CORE_PLUGIN_ID } from "@/lib/audit/const"; + +/** A declared audit action, with the plugin it is recorded under. */ +export interface RegisteredAuditAction extends AuditActionDefinition { + pluginId: string; +} + +/** + * Every audit action an install can record: core's own, the Content Engine's + * for each plugin that ships content types, and each plugin's declared + * `auditActions`. Read once at boot for the Activity Logs filters. + */ +export const collectAuditActions = ( + plugins: readonly { + auditActions?: readonly AuditActionDefinition[]; + contentTypes?: readonly { id: string }[]; + pluginId: string; + }[], +): RegisteredAuditAction[] => { + const actions: RegisteredAuditAction[] = CORE_AUDIT_ACTIONS.map(entry => ({ + ...entry, + pluginId: AUDIT_CORE_PLUGIN_ID, + })); + + for (const plugin of plugins) { + if ((plugin.contentTypes ?? []).length > 0) { + actions.push( + ...CONTENT_AUDIT_ACTIONS.map(action => ({ + action, + pluginId: plugin.pluginId, + targetType: "content", + })), + ); + } + for (const entry of plugin.auditActions ?? []) { + actions.push({ ...entry, pluginId: plugin.pluginId }); + } + } + + return actions; +}; + +/** What the Activity Logs filters offer: the declared actions and the installed plugins. */ +export const auditFilterOptions = (core: { + auditActions: readonly RegisteredAuditAction[]; + installedPlugins: { + plugins: readonly { icon: null | string; id: string; title: string }[]; + }; +}) => ({ + actions: core.auditActions.map(({ action, pluginId, targetType }) => ({ + action, + pluginId, + targetType, + })), + // Core is not an installed plugin, but its own entries need a filter too. + plugins: [ + ...(core.installedPlugins.plugins.some( + plugin => plugin.id === AUDIT_CORE_PLUGIN_ID, + ) + ? [] + : [{ icon: null, id: AUDIT_CORE_PLUGIN_ID, title: "VitNode" }]), + ...core.installedPlugins.plugins.map(({ icon, id, title }) => ({ + icon, + id, + title, + })), + ], +}); + +/** {@link auditFilterOptions} as an OpenAPI schema. */ +export const zodAuditFilterOptions = z.object({ + actions: z.array( + z.object({ + action: z.string(), + pluginId: z.string(), + targetType: z.string(), + }), + ), + /** Installed plugins. An entry naming any other plugin id is from a removed one. */ + plugins: z.array( + z.object({ + icon: z.string().nullable(), + id: z.string(), + title: z.string(), + }), + ), +}); diff --git a/packages/vitnode/src/api/lib/audit/retention.test.ts b/packages/vitnode/src/api/lib/audit/retention.test.ts new file mode 100644 index 0000000000..d5022d291e --- /dev/null +++ b/packages/vitnode/src/api/lib/audit/retention.test.ts @@ -0,0 +1,86 @@ +import type { SQL } from "drizzle-orm"; + +import { inArray, is, Param } from "drizzle-orm"; +import { describe, expect, it } from "vitest"; + +import { core_audit_logs } from "@/database/audit"; +import { createMemoryDb } from "@/tests/memory-db"; + +import type { AuditDatabase } from "./write"; + +import { pruneAuditLogs } from "./retention"; + +const NOW = new Date("2026-10-24T12:00:00.000Z"); +const DAY = 86_400_000; + +const entry = (id: number, ageInDays: number) => ({ + action: "role.updated", + actorType: "staff", + area: "admin", + createdAt: new Date(NOW.getTime() - ageInDays * DAY), + id, + metadata: {}, + pluginId: "@vitnode/core", + targetType: "role", +}); + +/** + * The memory db cannot evaluate a subquery, so the `id in (select ...)` of the + * retention sweep is resolved first - with the sweep's own select - and the + * delete runs against the ids it found. + */ +const harness = () => { + const memory = createMemoryDb([ + [ + core_audit_logs, + [entry(1, 400), entry(2, 366), entry(3, 364), entry(4, 1)], + ], + ]); + const db = { + delete: (table: typeof core_audit_logs) => ({ + where: (condition: SQL) => ({ + returning: async (fields: Record) => { + // Drizzle wraps a select it does not know as a bound parameter. + const subquery = condition.queryChunks + .map(chunk => (is(chunk, Param) ? chunk.value : chunk)) + .find( + (chunk): chunk is PromiseLike<{ id: number }[]> => + typeof chunk === "object" && chunk !== null && "then" in chunk, + ); + const ids = subquery ? (await subquery).map(row => row.id) : []; + + return await memory.db + .delete(table) + .where(inArray(core_audit_logs.id, ids.length > 0 ? ids : [-1])) + .returning(fields); + }, + }), + }), + select: memory.db.select, + } as unknown as AuditDatabase; + + return { + db, + remaining: () => memory.rows(core_audit_logs).map(row => row.id), + }; +}; + +describe("pruneAuditLogs", () => { + it("deletes only entries older than the retention period and counts them", async () => { + const h = harness(); + + await expect( + pruneAuditLogs(h.db, { now: NOW, retentionDays: 365 }), + ).resolves.toBe(2); + expect(h.remaining()).toEqual([3, 4]); + }); + + it("deletes nothing when retention keeps everything", async () => { + const h = harness(); + + await expect( + pruneAuditLogs(h.db, { now: NOW, retentionDays: 0 }), + ).resolves.toBe(0); + expect(h.remaining()).toEqual([1, 2, 3, 4]); + }); +}); diff --git a/packages/vitnode/src/api/lib/audit/retention.ts b/packages/vitnode/src/api/lib/audit/retention.ts new file mode 100644 index 0000000000..8ab4cbdde6 --- /dev/null +++ b/packages/vitnode/src/api/lib/audit/retention.ts @@ -0,0 +1,47 @@ +import { inArray, lt } from "drizzle-orm"; + +import { core_audit_logs } from "@/database/audit"; +import { AUDIT_RETENTION_BATCH_SIZE } from "@/lib/audit/const"; + +import type { AuditDatabase } from "./write"; + +/** Batches one retention run deletes at most, so a huge backlog spreads over several runs. */ +export const AUDIT_RETENTION_MAX_BATCHES = 100; + +/** + * Deletes entries older than `retentionDays`, oldest first, in batches of + * {@link AUDIT_RETENTION_BATCH_SIZE} - each its own statement, so the table is + * never locked for long. `0` keeps everything. Only the retention cron calls + * this; no route deletes an entry. + * + * @returns How many entries were deleted. + */ +export const pruneAuditLogs = async ( + db: AuditDatabase, + { now = new Date(), retentionDays }: { now?: Date; retentionDays: number }, +): Promise => { + if (retentionDays <= 0) return 0; + + const cutoff = new Date(now.getTime() - retentionDays * 86_400_000); + let deleted = 0; + for (let batch = 0; batch < AUDIT_RETENTION_MAX_BATCHES; batch++) { + const rows = await db + .delete(core_audit_logs) + .where( + inArray( + core_audit_logs.id, + db + .select({ id: core_audit_logs.id }) + .from(core_audit_logs) + .where(lt(core_audit_logs.createdAt, cutoff)) + .orderBy(core_audit_logs.createdAt) + .limit(AUDIT_RETENTION_BATCH_SIZE), + ), + ) + .returning({ id: core_audit_logs.id }); + deleted += rows.length; + if (rows.length < AUDIT_RETENTION_BATCH_SIZE) break; + } + + return deleted; +}; diff --git a/packages/vitnode/src/api/lib/audit/settings.ts b/packages/vitnode/src/api/lib/audit/settings.ts new file mode 100644 index 0000000000..78dcf578f8 --- /dev/null +++ b/packages/vitnode/src/api/lib/audit/settings.ts @@ -0,0 +1,34 @@ +import { eq } from "drizzle-orm"; + +import { core_audit_settings } from "@/database/audit"; +import { AUDIT_DEFAULT_RETENTION_DAYS } from "@/lib/audit/const"; + +import type { AuditDatabase } from "./write"; + +/** The Activity Logs settings, with their defaults applied. */ +export interface AuditSettings { + /** Days an entry is kept. `0` keeps everything. */ + retentionDays: number; +} + +export const loadAuditSettings = async ( + db: AuditDatabase, +): Promise => { + const [row] = await db + .select({ retentionDays: core_audit_settings.retentionDays }) + .from(core_audit_settings) + .where(eq(core_audit_settings.id, 1)) + .limit(1); + + return { retentionDays: row?.retentionDays ?? AUDIT_DEFAULT_RETENTION_DAYS }; +}; + +export const saveAuditSettings = async ( + db: AuditDatabase, + settings: AuditSettings, +): Promise => { + await db + .insert(core_audit_settings) + .values({ id: 1, ...settings }) + .onConflictDoUpdate({ set: settings, target: core_audit_settings.id }); +}; diff --git a/packages/vitnode/src/api/lib/audit/write.test.ts b/packages/vitnode/src/api/lib/audit/write.test.ts new file mode 100644 index 0000000000..a16284a6df --- /dev/null +++ b/packages/vitnode/src/api/lib/audit/write.test.ts @@ -0,0 +1,284 @@ +import type { Context } from "hono"; + +import { describe, expect, it, vi } from "vitest"; + +import { AuditModel } from "@/api/models/audit"; +import { core_audit_logs } from "@/database/audit"; +import { core_roles } from "@/database/roles"; +import { core_users } from "@/database/users"; +import { createMemoryDb } from "@/tests/memory-db"; + +import type { AuditWriteInput } from "./write"; + +import { AUDIT_SYSTEM_ACTOR, withAuditContext } from "./context"; +import { AuditLogError, writeAuditLog } from "./write"; + +const ADMIN = { id: 1, name: "Admin" }; +const MEMBER = { id: 2, name: "Member" }; +const IP = "203.0.113.7"; + +const ENTRY: AuditWriteInput = { + action: "category.updated", + target: { id: 5, label: "News", type: "category" }, +}; + +const harness = (vars: Record = {}) => { + const memory = createMemoryDb([ + [core_users, [{ ...ADMIN }, { ...MEMBER }, { id: 3, name: "Booker" }]], + [core_roles, []], + ]); + const values: Record = { + db: memory.db, + ipAddress: IP, + ...vars, + }; + const c = { get: (key: string) => values[key] } as unknown as Context; + + return { + audit: new AuditModel(c), + c, + entries: () => memory.rows(core_audit_logs), + memory, + }; +}; + +describe("writeAuditLog", () => { + it.each([ + [ + "an AdminCP admin as staff", + { admin: { user: ADMIN } }, + undefined, + { + actorId: ADMIN.id, + actorName: "Admin", + actorType: "staff", + area: "admin", + ipAddress: IP, + }, + ], + [ + "a signed-in member as api", + { user: MEMBER }, + undefined, + { actorId: MEMBER.id, actorType: "api", area: "public", ipAddress: IP }, + ], + [ + "a member in the moderator area as staff", + { user: MEMBER }, + "moderator" as const, + { actorId: MEMBER.id, actorType: "staff", area: "moderator" }, + ], + [ + "nobody as the system, without an IP address", + {}, + undefined, + { + actorId: null, + actorName: null, + actorType: "system", + area: "system", + ipAddress: null, + }, + ], + ])("attributes %s", async (_label, vars, area, expected) => { + const h = harness(vars); + + await writeAuditLog(h.c, { ...ENTRY, ...(area ? { area } : {}) }); + + expect(h.entries()).toEqual([expect.objectContaining(expected)]); + }); + + it("stores ids as text and sanitizes changes and metadata", async () => { + const h = harness({ admin: { user: ADMIN } }); + + await writeAuditLog(h.c, { + ...ENTRY, + changes: { apiKey: { from: "a", to: "b" } }, + metadata: { token: "secret", topicId: 9 }, + scope: { family: "forum", id: 4 }, + }); + + expect(h.entries()[0]).toMatchObject({ + changes: { apiKey: { redacted: true } }, + metadata: { token: "[redacted]", topicId: 9 }, + pluginId: "@vitnode/core", + scopeFamily: "forum", + scopeId: "4", + targetId: "5", + }); + }); + + it.each([ + ["an invalid action", { ...ENTRY, action: "Category Updated" }], + ["an invalid target type", { ...ENTRY, target: { type: "Category!" } }], + ])( + "throws AuditLogError for %s and writes nothing", + async (_label, input) => { + const h = harness(); + + await expect(writeAuditLog(h.c, input)).rejects.toBeInstanceOf( + AuditLogError, + ); + expect(h.entries()).toEqual([]); + }, + ); + + it("leaves no entry when the transaction it was written in rolls back", async () => { + const h = harness({ admin: { user: ADMIN } }); + + await expect( + h.memory.db.transaction(async tx => { + await writeAuditLog(h.c, ENTRY, tx as Context["var"]["db"]); + throw new Error("the change failed after the entry was written"); + }), + ).rejects.toThrow("the change failed"); + + expect(h.entries()).toEqual([]); + }); + + it("shares one correlation id across a request's entries", async () => { + const h = harness({ admin: { user: ADMIN } }); + + await writeAuditLog(h.c, ENTRY); + await writeAuditLog(h.c, { ...ENTRY, action: "category.deleted" }); + + const [first, second] = h.entries(); + expect(first.correlationId).toEqual(expect.any(String)); + expect(second.correlationId).toBe(first.correlationId); + }); +}); + +describe("withAuditContext", () => { + it("applies a frame's actor, area, scope, correlation id and metadata", async () => { + const h = harness({ admin: { user: ADMIN } }); + + await withAuditContext( + h.c, + { + actor: AUDIT_SYSTEM_ACTOR, + area: "system", + correlationId: "schedule:9", + metadata: { scheduledByUserId: 3, trigger: "frame" }, + scope: { family: "forum", id: "4" }, + }, + async () => + await writeAuditLog(h.c, { ...ENTRY, metadata: { trigger: "own" } }), + ); + + expect(h.entries()[0]).toMatchObject({ + actorId: null, + actorType: "system", + area: "system", + correlationId: "schedule:9", + ipAddress: null, + metadata: { scheduledByUserId: 3, trigger: "own" }, + scopeFamily: "forum", + scopeId: "4", + }); + }); + + it("lets an intercept rewrite an entry, or drop it with null", async () => { + const h = harness({ admin: { user: ADMIN } }); + + const results = await withAuditContext( + h.c, + { + intercept: entry => + entry.action === "category.deleted" + ? null + : { ...entry, action: "category.moved" }, + }, + async () => [ + await writeAuditLog(h.c, ENTRY), + await writeAuditLog(h.c, { ...ENTRY, action: "category.deleted" }), + ], + ); + + expect(results[1]).toBeNull(); + expect(h.entries().map(entry => entry.action)).toEqual(["category.moved"]); + }); + + it("applies nested frames innermost first", async () => { + const h = harness({ user: MEMBER }); + const seen: string[] = []; + + await withAuditContext( + h.c, + { + area: "public", + intercept: entry => { + seen.push(`outer:${entry.action}`); + + return entry; + }, + metadata: { from: "outer", outer: true }, + }, + async () => + await withAuditContext( + h.c, + { + area: "moderator", + intercept: entry => { + seen.push(`inner:${entry.action}`); + + return { ...entry, action: "report.claimed" }; + }, + metadata: { from: "inner" }, + }, + async () => await writeAuditLog(h.c, ENTRY), + ), + ); + + expect(seen).toEqual(["inner:category.updated", "outer:report.claimed"]); + expect(h.entries()[0]).toMatchObject({ + actorType: "staff", + area: "moderator", + metadata: { from: "inner", outer: true }, + }); + }); + + it("stops applying a frame once its block has finished", async () => { + const h = harness({ admin: { user: ADMIN } }); + + await withAuditContext(h.c, { area: "moderator" }, async () => + Promise.resolve(), + ); + await writeAuditLog(h.c, ENTRY); + + expect(h.entries()[0].area).toBe("admin"); + }); +}); + +describe("AuditModel.record", () => { + it("swallows a database error with bestEffort and logs only its name", async () => { + const error = vi.fn(async () => Promise.resolve()); + const h = harness({ log: { error } }); + vi.spyOn(h.memory.db, "insert").mockImplementation(() => { + throw new Error("connection refused: postgres://user:pw@db"); + }); + + await expect(h.audit.record(ENTRY, { bestEffort: true })).resolves.toBe( + null, + ); + expect(error).toHaveBeenCalledWith( + expect.not.stringContaining("postgres://"), + ); + }); + + it("throws a database error without bestEffort", async () => { + const h = harness(); + vi.spyOn(h.memory.db, "insert").mockImplementation(() => { + throw new Error("connection refused"); + }); + + await expect(h.audit.record(ENTRY)).rejects.toThrow("connection refused"); + }); + + it("never swallows an AuditLogError, even with bestEffort", async () => { + const h = harness(); + + await expect( + h.audit.record({ ...ENTRY, action: "BAD" }, { bestEffort: true }), + ).rejects.toBeInstanceOf(AuditLogError); + }); +}); diff --git a/packages/vitnode/src/api/lib/audit/write.ts b/packages/vitnode/src/api/lib/audit/write.ts new file mode 100644 index 0000000000..453cbd7963 --- /dev/null +++ b/packages/vitnode/src/api/lib/audit/write.ts @@ -0,0 +1,274 @@ +import type { Context } from "hono"; + +import { eq } from "drizzle-orm"; + +import type { + AuditArea, + AuditChanges, + AuditScope, + AuditTarget, +} from "@/lib/audit/const"; + +import { core_audit_logs } from "@/database/audit"; +import { core_users } from "@/database/users"; +import { + AUDIT_ACTION_MAX_LENGTH, + AUDIT_ACTION_PATTERN, + AUDIT_AREAS, + AUDIT_CORE_PLUGIN_ID, + AUDIT_PLUGIN_ID_MAX_LENGTH, + AUDIT_SCOPE_FAMILY_MAX_LENGTH, + AUDIT_SCOPE_ID_MAX_LENGTH, + AUDIT_TARGET_ID_MAX_LENGTH, + AUDIT_TARGET_LABEL_MAX_LENGTH, + AUDIT_TARGET_TYPE_MAX_LENGTH, + AUDIT_TARGET_TYPE_PATTERN, +} from "@/lib/audit/const"; +import { + sanitizeAuditChanges, + sanitizeAuditMetadata, +} from "@/lib/audit/sanitize"; + +import type { AuditActor, AuditPendingEntry } from "./context"; + +import { + AUDIT_SYSTEM_ACTOR, + auditContextFrames, + auditCorrelationId, +} from "./context"; + +/** A database handle or an open transaction to write the entry with. */ +export type AuditDatabase = Omit; + +/** An audit entry that cannot be stored as written - a bug in the caller. */ +export class AuditLogError extends Error { + constructor(message: string) { + super(message); + this.name = "AuditLogError"; + } +} + +/** Everything one entry can carry, before context is applied. */ +export interface AuditWriteInput { + action: string; + /** Trusted server-side attribution. Omitted, the request decides. */ + actor?: AuditActor; + area?: AuditArea; + changes?: AuditChanges | null; + contentTypeId?: null | string; + metadata?: Record; + pluginId?: string; + revisionId?: null | number; + scope?: AuditScope | null; + target: AuditTarget; +} + +const cut = (value: string, max: number): string => + value.length > max ? `${value.slice(0, max - 1)}…` : value; + +/** + * Who the request is acting as: the AdminCP admin as staff, a signed-in user + * as staff in the ModeratorCP or as `api` elsewhere, and nobody as the system. + * Only the session decides - never a value from the request body. + */ +export const resolveAuditActor = (c: Context, area: AuditArea): AuditActor => { + const adminId = c.get("admin")?.user.id; + if (typeof adminId === "number") return { type: "staff", userId: adminId }; + + const userId = c.get("user")?.id; + if (typeof userId === "number") { + return { type: area === "moderator" ? "staff" : "api", userId }; + } + + return AUDIT_SYSTEM_ACTOR; +}; + +/** The area a request writes from when nobody says otherwise. */ +export const resolveAuditArea = (c: Context): AuditArea => { + if (c.get("admin")) return "admin"; + if (c.get("user")) return "public"; + + return "system"; +}; + +const actorName = async ( + c: Context, + db: AuditDatabase, + actor: AuditActor, +): Promise => { + if (actor.userId === null) return null; + const admin = c.get("admin")?.user; + if (admin?.id === actor.userId) return admin.name; + const user = c.get("user"); + if (user?.id === actor.userId) return user.name; + + const [row] = await db + .select({ name: core_users.name }) + .from(core_users) + .where(eq(core_users.id, actor.userId)) + .limit(1); + + return row?.name ?? null; +}; + +const validate = (entry: AuditPendingEntry): void => { + if ( + entry.action.length > AUDIT_ACTION_MAX_LENGTH || + !AUDIT_ACTION_PATTERN.test(entry.action) + ) { + throw new AuditLogError( + `Audit action "${entry.action}" must be lowercase words joined by dots, like "category.updated".`, + ); + } + if ( + entry.target.type.length > AUDIT_TARGET_TYPE_MAX_LENGTH || + !AUDIT_TARGET_TYPE_PATTERN.test(entry.target.type) + ) { + throw new AuditLogError( + `Audit target type "${entry.target.type}" must be a short lowercase identifier, like "category".`, + ); + } + if ( + entry.pluginId === "" || + entry.pluginId.length > AUDIT_PLUGIN_ID_MAX_LENGTH + ) { + throw new AuditLogError( + "An audit entry needs the id of the plugin it belongs to.", + ); + } + if (!(AUDIT_AREAS as readonly string[]).includes(entry.area)) { + throw new AuditLogError(`Unknown audit area "${entry.area}".`); + } +}; + +/** + * Resolves context, applies the active {@link withAuditContext} frames, + * sanitizes and writes one entry. Inside `tx` the entry commits or rolls back + * with the caller's change; a failed insert throws, so a critical operation + * cannot commit without its entry. + * + * Internal to core: plugins call `c.get("audit").record()`. + * + * @returns The new entry's id, or `null` when a frame dropped it. + */ +export const writeAuditLog = async ( + c: Context, + input: AuditWriteInput, + tx?: AuditDatabase, +): Promise => { + const db = tx ?? c.get("db"); + const frames = auditContextFrames(c); + const framed = (key: TKey) => + frames.find(frame => frame[key] !== undefined)?.[key]; + + const area = input.area ?? framed("area") ?? resolveAuditArea(c); + const resolved = input.actor ?? framed("actor") ?? resolveAuditActor(c, area); + // A member acting from a moderator tool is acting as staff, whatever the + // write path called them. + const actor: AuditActor = + area === "moderator" && resolved.type === "api" + ? { ...resolved, type: "staff" } + : resolved; + const frameMetadata = Object.assign( + {}, + ...[...frames].reverse().map(frame => frame.metadata ?? {}), + ) as Record; + + let entry: AuditPendingEntry | null = { + action: input.action, + actor, + area, + changes: input.changes ?? null, + contentTypeId: input.contentTypeId ?? null, + metadata: { ...frameMetadata, ...input.metadata }, + pluginId: input.pluginId ?? c.get("plugin")?.id ?? AUDIT_CORE_PLUGIN_ID, + revisionId: input.revisionId ?? null, + scope: input.scope + ? { family: input.scope.family, id: String(input.scope.id) } + : (frames.find(frame => frame.scope)?.scope ?? null), + target: { + id: + input.target.id === undefined || input.target.id === null + ? null + : String(input.target.id), + label: input.target.label ?? null, + type: input.target.type, + }, + }; + + for (const frame of frames) { + if (!frame.intercept) continue; + entry = frame.intercept(entry); + if (!entry) return null; + } + + validate(entry); + + const [row] = await db + .insert(core_audit_logs) + .values({ + action: entry.action, + actorId: entry.actor.userId, + actorName: await actorName(c, db, entry.actor), + actorType: entry.actor.type, + area: entry.area, + changes: sanitizeAuditChanges(entry.changes), + contentTypeId: entry.contentTypeId, + correlationId: auditCorrelationId(c), + // The address of whoever acted. A system entry has nobody behind it, and + // the address of the cron caller would only be noise with a privacy cost. + ipAddress: + entry.actor.type === "system" ? null : (c.get("ipAddress") ?? null), + metadata: sanitizeAuditMetadata(entry.metadata), + pluginId: entry.pluginId, + revisionId: entry.revisionId, + scopeFamily: entry.scope + ? cut(entry.scope.family, AUDIT_SCOPE_FAMILY_MAX_LENGTH) + : null, + scopeId: entry.scope + ? cut(entry.scope.id, AUDIT_SCOPE_ID_MAX_LENGTH) + : null, + targetId: + entry.target.id === null + ? null + : cut(entry.target.id, AUDIT_TARGET_ID_MAX_LENGTH), + targetLabel: + entry.target.label === null + ? null + : cut(entry.target.label, AUDIT_TARGET_LABEL_MAX_LENGTH), + targetType: entry.target.type, + }) + .returning({ id: core_audit_logs.id }); + + return row?.id ?? null; +}; + +/** + * {@link writeAuditLog} for an operation that has already happened outside a + * transaction this code owns (sessions revoked through another model, a + * setting saved by a helper): a failed write is reported to the technical log + * - without the payload - instead of failing a request whose change stands. + */ +export const writeAuditLogBestEffort = async ( + c: Context, + input: AuditWriteInput, +): Promise => { + try { + return await writeAuditLog(c, input); + } catch (error) { + if (error instanceof AuditLogError) throw error; + try { + await c + .get("log") + ?.error( + `[audit] Could not record "${input.action}" on ${input.target.type}: ${ + error instanceof Error ? error.name : "unknown error" + }`, + ); + } catch { + // The logger writes to the same database; nothing more to do. + } + + return null; + } +}; diff --git a/packages/vitnode/src/api/lib/plugin.ts b/packages/vitnode/src/api/lib/plugin.ts index 91f677fc3a..cdf98637b2 100644 --- a/packages/vitnode/src/api/lib/plugin.ts +++ b/packages/vitnode/src/api/lib/plugin.ts @@ -7,6 +7,7 @@ import type { RegisteredContentType } from "@/content/registry"; import type { AnyContentModel } from "@/content/server/model"; import type { ContentNotificationsConfig } from "@/content/server/notifications/define"; import type { AnyContentTypeDefinition } from "@/content/types"; +import type { AuditActionDefinition } from "@/lib/audit/actions"; import type { ApiPluginContract } from "@/lib/fetcher/contract"; import type { LocaleMessagesMap } from "@/lib/i18n/types"; import type { PluginManifest } from "@/lib/plugin-manifest"; @@ -66,6 +67,8 @@ export interface BuildPluginApiReturn< readonly BaseBuildModuleReturn

[], > { aiActions?: AnyAiActionDefinition[]; + /** The audit actions the plugin records, for the Activity Logs filters. */ + auditActions?: readonly AuditActionDefinition[]; blocks?: BlockPluginSource; contentContainers?: AnyContentContainerDefinition[]; contentModels?: AnyContentModel[]; @@ -147,6 +150,7 @@ export function buildApiPlugin< const Modules extends readonly BuildModuleReturn[] = readonly [], >({ aiActions, + auditActions, blocks, contentNotifications, editablePages, @@ -163,6 +167,12 @@ export function buildApiPlugin< * `runAsSystem()`; admins configure them and grant their permission per role. */ aiActions?: AnyAiActionDefinition[]; + /** + * The audit actions the plugin records with `c.get("audit").record()`, from + * `defineAuditActions`. Optional: an undeclared action is still recorded and + * shown - declaring it only lists it in the Activity Logs action filter. + */ + auditActions?: readonly AuditActionDefinition[]; /** The object the plugin's own widgets (`blocks`) module exports; its `pluginId` must match. */ blocks?: BlockPluginSource; /** @@ -311,6 +321,7 @@ export function buildApiPlugin< return { pluginId, aiActions, + auditActions, blocks, editablePages: registeredPages.map(entry => entry.page), manifest, diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts index 58ec340dd4..4ccbfade88 100644 --- a/packages/vitnode/src/api/middlewares/global.middleware.ts +++ b/packages/vitnode/src/api/middlewares/global.middleware.ts @@ -20,6 +20,7 @@ import { DEFAULT_SESSION_COOKIE_NAME } from "@/api/lib/auth-cookie"; import { CacheModel } from "@/api/lib/cache"; import { MemoryCacheStore } from "@/api/lib/cache-memory"; import { AIModel } from "@/api/models/ai"; +import { AuditModel } from "@/api/models/audit"; import { EmailModel } from "@/api/models/email"; import { EventsModel } from "@/api/models/events"; import { I18nModel } from "@/api/models/i18n"; @@ -52,6 +53,7 @@ import { manifest as coreManifest } from "@/manifest"; import { realtime, runWithRealtimeKeepAlive } from "@/ws/registry"; import type { AiActionRegistry } from "../lib/ai/registry"; +import type { RegisteredAuditAction } from "../lib/audit/registry"; import type { BuildCronReturn } from "../lib/cron"; import type { RegisteredEditablePage } from "../lib/editable-pages"; import type { EventListenerConfig } from "../lib/events"; @@ -73,6 +75,7 @@ import type { SSOApiPlugin } from "../models/sso"; import { assertAiModelPricing } from "../lib/ai/pricing"; import { assertContentAiActions, collectAiActions } from "../lib/ai/registry"; +import { collectAuditActions } from "../lib/audit/registry"; import { resolveClientIp } from "../lib/client-ip"; import { collectCronJobs } from "../lib/cron"; import { @@ -158,6 +161,11 @@ export interface EnvVariablesVitNode { adminSessionExpiresAt: Date | null; /** The AI model registry and action runner: see {@link AIModel}. */ ai: AIModel; + /** + * Records who did what to which resource in the central audit log (the + * AdminCP staff logs): see {@link AuditModel}. + */ + audit: AuditModel; /** Plugin-scoped JSON cache, Redis-backed when the API config has `redis`, a no-op otherwise. */ cache: CacheModel; /** @@ -168,6 +176,8 @@ export interface EnvVariablesVitNode { core: { ai?: VitNodeApiConfig["ai"]; aiActions: AiActionRegistry; + /** Every declared audit action - core's, the Content Engine's and each plugin's. */ + auditActions: RegisteredAuditAction[]; authorization: { adminCookieExpires: number; adminCookieName: string; @@ -394,6 +404,8 @@ export const globalMiddleware = ({ const aiActionsMetadata = collectAiActions(plugins); + const auditActionsMetadata = collectAuditActions(plugins); + const eventsMetadata: EventListenerConfig[] = plugins.flatMap(plugin => (plugin.events ?? []).map(listener => ({ ...listener, @@ -580,6 +592,7 @@ export const globalMiddleware = ({ { ai, aiActions: aiActionsMetadata, + auditActions: auditActionsMetadata, i18n: i18nMetadata, metadata, email, @@ -659,6 +672,7 @@ export const globalMiddleware = ({ } c.set("db", dbProvider); c.set("ai", new AIModel(c)); + c.set("audit", new AuditModel(c)); c.set("cache", new CacheModel(cacheClient, c, memoryCache)); c.set("email", new EmailModel(c)); c.set("events", new EventsModel(c)); diff --git a/packages/vitnode/src/api/models/audit.ts b/packages/vitnode/src/api/models/audit.ts new file mode 100644 index 0000000000..0cee5c93f5 --- /dev/null +++ b/packages/vitnode/src/api/models/audit.ts @@ -0,0 +1,130 @@ +import type { Context } from "hono"; + +import type { + AuditArea, + AuditChanges, + AuditScope, + AuditTarget, +} from "@/lib/audit/const"; +import type { AuditDiffArgs } from "@/lib/audit/diff"; + +import { auditDiff } from "@/lib/audit/diff"; + +import type { AuditDatabase } from "../lib/audit/write"; + +import { writeAuditLog, writeAuditLogBestEffort } from "../lib/audit/write"; + +/** One operation to record with `c.get("audit").record()`. */ +export interface AuditRecordInput { + /** + * A stable identifier, lowercase words joined by dots, unique within the + * plugin: `topic.moved`, `category.updated`. It is stored as-is and is what + * the UI translates, so never rename one that has shipped. + */ + action: string; + /** + * Where the operation was performed. Defaults to `admin` on an AdminCP + * request, `public` for a signed-in user and `system` otherwise - pass + * `moderator` from moderation tools, which files the entry in the + * moderators' log instead of the admins'. + */ + area?: AuditArea; + /** Before/after values, usually from `c.get("audit").diff()`. Sensitive fields are always redacted. */ + changes?: AuditChanges | null; + /** + * Small, non-personal facts worth keeping (`{ fromForumId: 3 }`). Sanitized + * and size-capped; never pass request bodies, errors or secrets. + */ + metadata?: Record; + /** Defaults to the plugin of the route handling the request. */ + pluginId?: string; + /** + * The moderation scope the target lives in, as the moderation registry + * names it (`{ family: "forum", id: 4 }`). + */ + scope?: AuditScope | null; + target: AuditTarget; +} + +export interface AuditRecordOptions { + /** + * Log a failed write to the technical log instead of throwing. Only for + * operations that are not security-relevant; never with `tx`. + */ + bestEffort?: boolean; + /** + * The transaction of the change being recorded. The entry then commits and + * rolls back with it, and a failed write fails the change - use it for + * anything security-relevant. + */ + tx?: AuditDatabase; +} + +/** + * `c.get("audit")`: records who did what, when and to which resource in the + * central audit log shown in AdminCP -> Staff -> Administrators / Moderators -> Logs. + * Server-only. + * + * The actor, IP address and correlation id always come from the request (or + * from a trusted `withAuditContext` frame for background work), never from the + * input. Content Engine writes are recorded automatically - call this for + * your plugin's own operations. + * + * @example + * ```ts + * await c.get("db").transaction(async tx => { + * const [before] = await tx.select().from(forum_topics).where(eq(forum_topics.id, id)); + * const [after] = await tx + * .update(forum_topics) + * .set({ forumId }) + * .where(eq(forum_topics.id, id)) + * .returning(); + * + * await c.get("audit").record( + * { + * action: "topic.moved", + * area: "moderator", + * target: { type: "topic", id, label: after.title }, + * changes: c.get("audit").diff({ before, after, include: ["forumId"] }), + * scope: { family: "forum", id: forumId }, + * }, + * { tx }, + * ); + * }); + * ``` + */ +export class AuditModel { + constructor(c: Context) { + this.c = c; + } + + protected readonly c: Context; + + /** + * Structured before/after changes between two objects - see `auditDiff` + * in `@vitnode/core/lib/audit/diff`. + */ + diff( + args: AuditDiffArgs, + ): AuditChanges | null { + return auditDiff(args); + } + + /** + * Writes one entry. + * + * @returns The entry's id, or `null` when it was folded into an enclosing + * operation, or when a best-effort write failed. + * @throws `AuditLogError` when the input is malformed (an invalid action or + * target type) - a bug, so it is never swallowed. Database errors are thrown + * too unless `bestEffort` is set. + */ + async record( + input: AuditRecordInput, + { bestEffort = false, tx }: AuditRecordOptions = {}, + ): Promise { + if (!bestEffort || tx) return await writeAuditLog(this.c, input, tx); + + return await writeAuditLogBestEffort(this.c, input); + } +} diff --git a/packages/vitnode/src/api/models/notifications/admin.ts b/packages/vitnode/src/api/models/notifications/admin.ts index 1596ac3b60..22165cc74a 100644 --- a/packages/vitnode/src/api/models/notifications/admin.ts +++ b/packages/vitnode/src/api/models/notifications/admin.ts @@ -3,6 +3,7 @@ import { HTTPException } from "hono/http-exception"; import type { NotificationTypePolicy } from "@/api/lib/notifications/preferences"; +import { writeAuditLogBestEffort } from "@/api/lib/audit/write"; import { isNotificationEmailAvailable, isNotificationInAppAvailable, @@ -17,6 +18,7 @@ import { core_notifications, } from "@/database/notifications"; import { core_queue } from "@/database/queue"; +import { auditDiff } from "@/lib/audit/diff"; import type { NotificationsContext } from "./shared"; @@ -236,6 +238,11 @@ export const updateNotificationTypePolicy = async ( } await upsertNotificationSetting(c, typeSettingsKey(typeId), { ...merged }); + await writeAuditLogBestEffort(c, { + action: "notifications.type_updated", + changes: auditDiff({ after: merged, before: policies.get(typeId) ?? {} }), + target: { id: typeId, label: typeId, type: "notification_type" }, + }); await c.get("events").emit("notifications.type.updated", { typeId }); return merged; diff --git a/packages/vitnode/src/api/modules/admin/activity-logs/activity-logs.admin.module.ts b/packages/vitnode/src/api/modules/admin/activity-logs/activity-logs.admin.module.ts new file mode 100644 index 0000000000..575b625ec6 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/activity-logs/activity-logs.admin.module.ts @@ -0,0 +1,25 @@ +import { buildModule } from "@/api/builders"; + +import { activityLogDetailAdminRoute } from "./routes/detail.route"; +import { activityLogEntityAdminRoute } from "./routes/entity.route"; +import { exportActivityLogsAdminRoute } from "./routes/export.route"; +import { activityLogFiltersAdminRoute } from "./routes/filters.route"; +import { listActivityLogsAdminRoute } from "./routes/list.route"; +import { + activityLogSettingsAdminRoute, + updateActivityLogSettingsAdminRoute, +} from "./routes/settings.route"; + +export const activityLogsAdminModule = buildModule({ + name: "activity-logs", + // The fixed paths first, so `/{id}` never shadows them. + routes: [ + listActivityLogsAdminRoute, + activityLogFiltersAdminRoute, + activityLogEntityAdminRoute, + exportActivityLogsAdminRoute, + activityLogSettingsAdminRoute, + updateActivityLogSettingsAdminRoute, + activityLogDetailAdminRoute, + ], +}); diff --git a/packages/vitnode/src/api/modules/admin/activity-logs/routes/activity-logs.routes.test.ts b/packages/vitnode/src/api/modules/admin/activity-logs/routes/activity-logs.routes.test.ts new file mode 100644 index 0000000000..f083d378e9 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/activity-logs/routes/activity-logs.routes.test.ts @@ -0,0 +1,334 @@ +import type { Context } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { describe, expect, it } from "vitest"; + +import type { PermissionsStaffArgs } from "@/api/lib/permission-staff"; + +import { core_audit_logs, core_audit_settings } from "@/database/audit"; +import { core_roles } from "@/database/roles"; +import { core_users } from "@/database/users"; +import { createTestCache } from "@/tests/cache"; +import { testCategoryContentType } from "@/tests/content-fixtures"; +import { createMemoryDb } from "@/tests/memory-db"; +import { grantStaffPermissions } from "@/tests/staff-permissions"; + +import { activityLogDetailAdminRoute } from "./detail.route"; +import { activityLogEntityAdminRoute } from "./entity.route"; +import { exportActivityLogsAdminRoute } from "./export.route"; +import { listActivityLogsAdminRoute } from "./list.route"; +import { updateActivityLogSettingsAdminRoute } from "./settings.route"; + +const BLOG = "@vitnode/blog"; +const MEMBER_ROLE = 3; + +const log = (permission: string): PermissionsStaffArgs => ({ + module: "activity_logs", + permission, + plugin: "@vitnode/core", +}); + +const ADMINS = { + editor: [ + { + module: testCategoryContentType.permissionModule, + permission: "can_view", + plugin: BLOG, + }, + ], + exporter: [log("can_export")], + nobody: [ + { module: "users", permission: "can_view", plugin: "@vitnode/core" }, + ], + retention: [log("can_manage_retention")], + sensitive: [log("can_view"), log("can_view_sensitive"), log("can_export")], + viewer: [log("can_view")], +} satisfies Record; + +type Admin = keyof typeof ADMINS; + +const ADMIN_IDS = Object.fromEntries( + Object.keys(ADMINS).map((name, index) => [name, index + 1]), +) as Record; + +const ENTRIES = [ + { + action: "role.updated", + actorId: 1, + actorName: "Editor", + actorType: "staff", + area: "admin", + correlationId: "corr-1", + createdAt: new Date("2026-10-01T00:00:00.000Z"), + id: 1, + ipAddress: "203.0.113.7", + metadata: {}, + pluginId: "@vitnode/core", + targetId: "5", + targetLabel: '=HYPERLINK("http://evil.test","Click")', + targetType: "role", + }, + { + action: "content.updated", + actorId: 1, + actorName: "Editor", + actorType: "staff", + area: "admin", + changes: { title: { from: "Old", to: "New" } }, + contentTypeId: testCategoryContentType.id, + createdAt: new Date("2026-10-02T00:00:00.000Z"), + id: 2, + ipAddress: "203.0.113.7", + metadata: {}, + pluginId: BLOG, + targetId: "7", + targetLabel: "News", + targetType: testCategoryContentType.id, + }, + { + action: "user.banned", + actorId: 1, + actorName: "Editor", + actorType: "staff", + area: "moderator", + createdAt: new Date("2026-10-03T00:00:00.000Z"), + id: 3, + metadata: {}, + pluginId: "@vitnode/core", + targetId: "9", + targetLabel: "Spammer", + targetType: "user", + }, + { + action: "audit_logs.pruned", + actorId: null, + actorName: null, + actorType: "system", + area: "system", + createdAt: new Date("2026-10-04T00:00:00.000Z"), + id: 4, + metadata: {}, + pluginId: "@vitnode/core", + targetType: "audit_logs", + }, +]; + +const harness = async () => { + const cache = createTestCache(); + for (const [name, permissions] of Object.entries(ADMINS)) { + await grantStaffPermissions(cache, { + permissions, + userId: ADMIN_IDS[name as Admin], + }); + } + const memory = createMemoryDb([ + [ + core_users, + Object.entries(ADMIN_IDS).map(([name, id]) => ({ + id, + name, + nameCode: name, + roleId: MEMBER_ROLE, + })), + ], + [core_roles, [{ color: null, id: MEMBER_ROLE, prefix: null }]], + [core_audit_logs, ENTRIES], + [core_audit_settings, []], + ]); + + const app = new OpenAPIHono(); + app.use("*", async (c, next) => { + const name = c.req.header("x-admin") as Admin; + c.set("admin", { + user: { id: ADMIN_IDS[name], name, roleId: MEMBER_ROLE }, + } as unknown as Context["var"]["admin"]); + c.set("cache", cache); + c.set("db", memory.db as unknown as Context["var"]["db"]); + c.set("core", { + contentModels: [ + { model: { definition: testCategoryContentType }, pluginId: BLOG }, + ], + } as unknown as Context["var"]["core"]); + await next(); + }); + for (const built of [ + exportActivityLogsAdminRoute, + activityLogEntityAdminRoute, + updateActivityLogSettingsAdminRoute, + activityLogDetailAdminRoute, + listActivityLogsAdminRoute, + ]) { + app.openapi(built.route, built.handler); + } + + const request = async (admin: Admin, path: string, init: RequestInit = {}) => + await app.request(path, { + ...init, + headers: { + "content-type": "application/json", + "x-admin": admin, + ...init.headers, + }, + }); + + const audits = (action: string) => + memory.rows(core_audit_logs).filter(row => row.action === action); + + return { audits, memory, request }; +}; + +const ENTITY = `/entity?pluginId=${encodeURIComponent(BLOG)}&targetType=${testCategoryContentType.id}&targetId=7`; + +describe("Activity Logs admin routes", () => { + it.each([ + ["the list", "/", "viewer", 200], + ["the list", "/", "nobody", 403], + [ + "an entity history with the content type's can_view only", + ENTITY, + "editor", + 200, + ], + ["an entity history", ENTITY, "nobody", 403], + ["the export", "/export", "exporter", 200], + ["the export", "/export", "viewer", 403], + ] as const)( + "answers %s (%s) for %s with %i", + async (_label, path, admin, status) => { + const h = await harness(); + + expect((await h.request(admin, path)).status).toBe(status); + }, + ); + + it("lists entries newest first", async () => { + const h = await harness(); + + const res = await h.request("viewer", "/"); + const body = (await res.json()) as { + edges: { id: number }[]; + pageInfo: { hasNextPage: boolean }; + }; + + expect(body.edges.map(edge => edge.id)).toEqual([4, 3, 2, 1]); + expect(body.pageInfo.hasNextPage).toBe(false); + }); + + it.each([ + ["?staff=admin", [4, 2, 1]], + ["?staff=moderator", [3]], + ] as const)("filters the list by staff log (%s)", async (query, ids) => { + const h = await harness(); + + const res = await h.request("viewer", `/${query}`); + const body = (await res.json()) as { edges: { id: number }[] }; + + expect(body.edges.map(edge => edge.id)).toEqual(ids); + }); + + it("shows an entity's history with its recorded changes", async () => { + const h = await harness(); + + const res = await h.request("editor", ENTITY); + + expect(await res.json()).toMatchObject({ + edges: [{ changes: { title: { from: "Old", to: "New" } }, id: 2 }], + }); + }); + + it.each([ + ["hides", "viewer", { correlationId: null, ipAddress: null }], + [ + "shows", + "sensitive", + { correlationId: "corr-1", ipAddress: "203.0.113.7" }, + ], + ] as const)( + "%s the IP address and correlation id in the detail for %s", + async (_label, admin, expected) => { + const h = await harness(); + + const res = await h.request(admin, "/1"); + + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ id: 1, ...expected }); + }, + ); + + describe("export", () => { + it("escapes formulas, omits IP addresses without can_view_sensitive and records itself", async () => { + const h = await harness(); + + const res = await h.request("exporter", "/export"); + const csv = await res.text(); + const [header] = csv.split("\r\n"); + + expect(res.headers.get("content-type")).toContain("text/csv"); + expect(header).not.toContain("ipAddress"); + expect(csv).not.toContain("203.0.113.7"); + expect(csv).toContain(`"'=HYPERLINK(""http://evil.test"",""Click"")"`); + expect(h.audits("audit_logs.exported")).toEqual([ + expect.objectContaining({ + actorId: ADMIN_IDS.exporter, + metadata: expect.objectContaining({ rows: 4 }), + }), + ]); + }); + + it("includes IP addresses with can_view_sensitive", async () => { + const h = await harness(); + + const csv = await (await h.request("sensitive", "/export")).text(); + + expect(csv.split("\r\n")[0]).toContain('"ipAddress"'); + expect(csv).toContain("203.0.113.7"); + }); + }); + + describe("PUT /settings", () => { + const put = async ( + h: Awaited>, + admin: Admin, + retentionDays: number, + ) => + await h.request(admin, "/settings", { + body: JSON.stringify({ retentionDays }), + method: "PUT", + }); + + it("saves an offered retention period and records the change", async () => { + const h = await harness(); + + const res = await put(h, "retention", 90); + + expect(res.status).toBe(200); + expect(h.memory.rows(core_audit_settings)).toEqual([ + expect.objectContaining({ id: 1, retentionDays: 90 }), + ]); + expect(h.audits("audit_settings.updated")).toEqual([ + expect.objectContaining({ + changes: { retentionDays: { from: 365, to: 90 } }, + }), + ]); + }); + + it.each([ + ["a period that is not offered", "retention", 45, 400], + ["an admin without can_manage_retention", "viewer", 90, 403], + ] as const)("refuses %s", async (_label, admin, days, status) => { + const h = await harness(); + + expect((await put(h, admin, days)).status).toBe(status); + expect(h.memory.rows(core_audit_settings)).toEqual([]); + expect(h.audits("audit_settings.updated")).toEqual([]); + }); + + it("records nothing when the period does not change", async () => { + const h = await harness(); + + await put(h, "retention", 365); + + expect(h.audits("audit_settings.updated")).toEqual([]); + }); + }); +}); diff --git a/packages/vitnode/src/api/modules/admin/activity-logs/routes/detail.route.ts b/packages/vitnode/src/api/modules/admin/activity-logs/routes/detail.route.ts new file mode 100644 index 0000000000..fa3f589751 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/activity-logs/routes/detail.route.ts @@ -0,0 +1,43 @@ +import { z } from "@hono/zod-openapi"; + +import { buildRoute } from "@/api/builders"; +import { AUDIT_PERMISSIONS, canAdminAudit } from "@/api/lib/audit/access"; +import { auditDetailOf } from "@/api/lib/audit/detail"; +import { findAuditLog, zodAuditDetail } from "@/api/lib/audit/query"; + +export const activityLogDetailAdminRoute = buildRoute({ + adminStaffPermission: { module: "activity_logs", permission: "can_view" }, + route: { + method: "get", + path: "/{id}", + description: + "One Activity Logs entry with its changes, metadata and the content revision it points at", + request: { + params: z.object({ + id: z.coerce.number().int().positive().openapi({ example: 1 }), + }), + }, + responses: { + 200: { + content: { "application/json": { schema: zodAuditDetail } }, + description: "The entry", + }, + 404: { description: "No such entry" }, + }, + }, + handler: async c => { + const { id } = c.req.valid("param"); + const entry = await findAuditLog(c, { access: undefined, id }); + if (!entry) return c.json({ error: "Not found" }, 404); + + return c.json( + await auditDetailOf(c, entry, { + canViewSensitive: await canAdminAudit( + c, + AUDIT_PERMISSIONS.viewSensitive, + ), + }), + 200, + ); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/activity-logs/routes/entity.route.ts b/packages/vitnode/src/api/modules/admin/activity-logs/routes/entity.route.ts new file mode 100644 index 0000000000..8bec079767 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/activity-logs/routes/entity.route.ts @@ -0,0 +1,81 @@ +import { z } from "@hono/zod-openapi"; +import { HTTPException } from "hono/http-exception"; + +import { buildRoute } from "@/api/builders"; +import { AUDIT_PERMISSIONS, canAdminAudit } from "@/api/lib/audit/access"; +import { + listAuditLogsWithDetails, + zodAuditEntityPage, +} from "@/api/lib/audit/query"; +import { checkStaffPermission } from "@/api/lib/check-staff-permission"; +import { contentDefinitionOf, findContentModel } from "@/content/server/model"; +import { + AUDIT_PLUGIN_ID_MAX_LENGTH, + AUDIT_TARGET_ID_MAX_LENGTH, + AUDIT_TARGET_TYPE_MAX_LENGTH, +} from "@/lib/audit/const"; + +/** + * Whether the admin may read one entity's history: with the Activity Logs + * permission always, and for a content record also with `can_view` on its own + * content type - an editor who can open a category can see who changed it, + * without being handed the whole site's log. + */ +const canReadEntityHistory = async ( + c: Parameters[0], + { pluginId, targetType }: { pluginId: string; targetType: string }, +): Promise => { + if (await canAdminAudit(c, AUDIT_PERMISSIONS.view)) return true; + + const registered = findContentModel(c.get("core").contentModels, targetType); + if (!registered || registered.pluginId !== pluginId) return false; + + return await checkStaffPermission(c, { + module: contentDefinitionOf(registered.model).permissionModule, + permission: "can_view", + plugin: registered.pluginId, + type: "admin", + }); +}; + +export const activityLogEntityAdminRoute = buildRoute({ + route: { + method: "get", + path: "/entity", + description: + "The activity history of one resource - a content record or a plugin's own target. Needs Activity Logs `can_view`, or `can_view` on the content type", + request: { + query: z.object({ + cursor: z.string().max(200).optional(), + first: z.coerce.number().int().min(1).max(100).optional(), + pluginId: z.string().min(1).max(AUDIT_PLUGIN_ID_MAX_LENGTH), + targetId: z.string().min(1).max(AUDIT_TARGET_ID_MAX_LENGTH), + targetType: z.string().min(1).max(AUDIT_TARGET_TYPE_MAX_LENGTH), + }), + }, + responses: { + 200: { + content: { "application/json": { schema: zodAuditEntityPage } }, + description: "One page of the resource's history, newest first", + }, + 403: { description: "May not read this resource's history" }, + }, + }, + handler: async c => { + const { cursor, first, pluginId, targetId, targetType } = + c.req.valid("query"); + if (!(await canReadEntityHistory(c, { pluginId, targetType }))) { + throw new HTTPException(403, { message: "Forbidden" }); + } + + return c.json( + await listAuditLogsWithDetails(c, { + access: undefined, + cursor, + filters: { pluginId, targetId, targetType }, + first, + }), + 200, + ); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/activity-logs/routes/export.route.ts b/packages/vitnode/src/api/modules/admin/activity-logs/routes/export.route.ts new file mode 100644 index 0000000000..b3016f83c4 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/activity-logs/routes/export.route.ts @@ -0,0 +1,83 @@ +import { z } from "@hono/zod-openapi"; + +import { buildRoute } from "@/api/builders"; +import { AUDIT_PERMISSIONS, canAdminAudit } from "@/api/lib/audit/access"; +import { exportAuditLogs, zodAuditFilters } from "@/api/lib/audit/query"; +import { writeAuditLog } from "@/api/lib/audit/write"; +import { AUDIT_EXPORT_MAX_ROWS } from "@/lib/audit/const"; +import { toCsv } from "@/lib/audit/csv"; + +export const exportActivityLogsAdminRoute = buildRoute({ + adminStaffPermission: { module: "activity_logs", permission: "can_export" }, + route: { + method: "get", + path: "/export", + description: `Download the filtered Activity Logs as CSV, newest first, at most ${AUDIT_EXPORT_MAX_ROWS} rows. IP addresses only with can_view_sensitive`, + request: { query: zodAuditFilters }, + responses: { + 200: { + content: { "text/csv": { schema: z.string() } }, + description: "CSV file", + }, + }, + }, + handler: async c => { + const filters = c.req.valid("query"); + const sensitive = await canAdminAudit(c, AUDIT_PERMISSIONS.viewSensitive); + const rows = await exportAuditLogs(c, { + access: undefined, + filters, + limit: AUDIT_EXPORT_MAX_ROWS, + }); + + // An export is a copy of the history leaving the system, so it is part of + // the history too. + await writeAuditLog(c, { + action: "audit_logs.exported", + metadata: { filters, rows: rows.length }, + target: { id: "activity_logs", label: "Activity Logs", type: "settings" }, + }); + + const csv = toCsv( + [ + "id", + "createdAt", + "area", + "pluginId", + "action", + "actorType", + "actorId", + "actorName", + "targetType", + "targetId", + "targetLabel", + "scope", + "changes", + ...(sensitive ? ["ipAddress"] : []), + ], + rows.map(row => [ + row.id, + row.createdAt, + row.area, + row.pluginId, + row.action, + row.actorType, + row.actorId, + row.actorName, + row.targetType, + row.targetId, + row.targetLabel, + row.scopeFamily ? `${row.scopeFamily}:${row.scopeId ?? ""}` : null, + row.changes, + ...(sensitive ? [row.ipAddress] : []), + ]), + ); + const date = new Date().toISOString().slice(0, 10); + + return c.body(csv, 200, { + "Cache-Control": "no-store", + "Content-Disposition": `attachment; filename="activity-logs-${date}.csv"`, + "Content-Type": "text/csv; charset=utf-8", + }); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/activity-logs/routes/filters.route.ts b/packages/vitnode/src/api/modules/admin/activity-logs/routes/filters.route.ts new file mode 100644 index 0000000000..6197ca1780 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/activity-logs/routes/filters.route.ts @@ -0,0 +1,22 @@ +import { buildRoute } from "@/api/builders"; +import { + auditFilterOptions, + zodAuditFilterOptions, +} from "@/api/lib/audit/registry"; + +export const activityLogFiltersAdminRoute = buildRoute({ + adminStaffPermission: { module: "activity_logs", permission: "can_view" }, + route: { + method: "get", + path: "/filters", + description: + "The plugins and declared actions the Activity Logs filters offer", + responses: { + 200: { + content: { "application/json": { schema: zodAuditFilterOptions } }, + description: "Filter options", + }, + }, + }, + handler: c => c.json(auditFilterOptions(c.get("core")), 200), +}); diff --git a/packages/vitnode/src/api/modules/admin/activity-logs/routes/list.route.ts b/packages/vitnode/src/api/modules/admin/activity-logs/routes/list.route.ts new file mode 100644 index 0000000000..9824025df6 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/activity-logs/routes/list.route.ts @@ -0,0 +1,32 @@ +import { buildRoute } from "@/api/builders"; +import { + listAuditLogs, + zodAuditPage, + zodAuditPageQuery, +} from "@/api/lib/audit/query"; + +export const listActivityLogsAdminRoute = buildRoute({ + adminStaffPermission: { module: "activity_logs", permission: "can_view" }, + route: { + method: "get", + path: "/", + description: + "Activity Logs: who did what to which resource, newest first, cursor-paginated and filtered", + request: { query: zodAuditPageQuery }, + responses: { + 200: { + content: { "application/json": { schema: zodAuditPage } }, + description: "One page of audit entries", + }, + 400: { description: "Invalid filter or cursor" }, + }, + }, + handler: async c => { + const { cursor, first, ...filters } = c.req.valid("query"); + + return c.json( + await listAuditLogs(c, { access: undefined, cursor, filters, first }), + 200, + ); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/activity-logs/routes/settings.route.ts b/packages/vitnode/src/api/modules/admin/activity-logs/routes/settings.route.ts new file mode 100644 index 0000000000..11b71ec105 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/activity-logs/routes/settings.route.ts @@ -0,0 +1,85 @@ +import { z } from "@hono/zod-openapi"; + +import { buildRoute } from "@/api/builders"; +import { loadAuditSettings, saveAuditSettings } from "@/api/lib/audit/settings"; +import { writeAuditLog } from "@/api/lib/audit/write"; +import { AUDIT_RETENTION_DAYS_OPTIONS } from "@/lib/audit/const"; +import { auditDiff } from "@/lib/audit/diff"; + +const zodSettings = z.object({ + retentionDays: z + .number() + .int() + .refine( + value => + (AUDIT_RETENTION_DAYS_OPTIONS as readonly number[]).includes(value), + { message: "Choose one of the offered retention periods." }, + ) + .openapi({ example: 365 }), +}); + +export const activityLogSettingsAdminRoute = buildRoute({ + adminStaffPermission: { module: "activity_logs", permission: "can_view" }, + route: { + method: "get", + path: "/settings", + description: "How long Activity Logs entries are kept", + responses: { + 200: { + content: { "application/json": { schema: zodSettings } }, + description: "The settings", + }, + }, + }, + handler: async c => c.json(await loadAuditSettings(c.get("db")), 200), +}); + +export const updateActivityLogSettingsAdminRoute = buildRoute({ + adminStaffPermission: { + module: "activity_logs", + permission: "can_manage_retention", + }, + route: { + method: "put", + path: "/settings", + description: + "Change how long Activity Logs entries are kept. The daily retention job deletes what is older", + request: { + body: { + required: true, + content: { "application/json": { schema: zodSettings } }, + }, + }, + responses: { + 200: { + content: { "application/json": { schema: zodSettings } }, + description: "Saved", + }, + }, + }, + handler: async c => { + const body = c.req.valid("json"); + await c.get("db").transaction(async tx => { + const before = await loadAuditSettings(tx); + await saveAuditSettings(tx, body); + const changes = auditDiff({ after: body, before }); + if (changes) { + await writeAuditLog( + c, + { + action: "audit_settings.updated", + changes, + target: { + id: "activity_logs", + label: "Activity Logs", + type: "settings", + }, + }, + tx, + ); + } + }); + + return c.json(body, 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/admin.module.ts b/packages/vitnode/src/api/modules/admin/admin.module.ts index 914e3579b3..0c25a69cdd 100644 --- a/packages/vitnode/src/api/modules/admin/admin.module.ts +++ b/packages/vitnode/src/api/modules/admin/admin.module.ts @@ -1,5 +1,6 @@ import { buildModule } from "@/api/builders"; +import { activityLogsAdminModule } from "./activity-logs/activity-logs.admin.module"; import { advancedAdminModule } from "./advanced/advanced.admin.module"; import { aiAdminModule } from "./ai/ai.admin.module"; import { dashboardAdminModule } from "./dashboard/dashboard.admin.module"; @@ -18,6 +19,7 @@ export const adminModule = buildModule({ name: "admin", routes: [sessionAdminRoute, sendNotificationRoute], modules: [ + activityLogsAdminModule, aiAdminModule, usersAdminModule, rolesAdminModule, diff --git a/packages/vitnode/src/api/modules/admin/advanced/cron/routes/run.route.ts b/packages/vitnode/src/api/modules/admin/advanced/cron/routes/run.route.ts index aaf5f40f74..31575ce005 100644 --- a/packages/vitnode/src/api/modules/admin/advanced/cron/routes/run.route.ts +++ b/packages/vitnode/src/api/modules/admin/advanced/cron/routes/run.route.ts @@ -4,6 +4,8 @@ import { z } from "zod"; import type { CronJobConfig } from "@/api/lib/cron"; import { buildRoute } from "@/api/builders"; +import { AUDIT_SYSTEM_ACTOR, withAuditContext } from "@/api/lib/audit/context"; +import { writeAuditLog } from "@/api/lib/audit/write"; import { core_cron } from "@/database/cron"; import { getNextCronRunDate } from "@/lib/api/get-next-cron-run-date"; @@ -86,7 +88,31 @@ export const runCronRoute = buildRoute({ const now = new Date(); try { - await job.handler(c); + // The job itself runs as the system - what it writes was not done by + // hand - while the admin who started it is named on its entries and + // recorded for starting it. + await withAuditContext( + c, + { + actor: AUDIT_SYSTEM_ACTOR, + area: "system", + metadata: { + cronJob: job.name, + trigger: "cron_manual", + triggeredByUserId: c.get("admin")?.user.id ?? null, + }, + }, + async () => { + await job.handler(c); + }, + ); + + await writeAuditLog(c, { + action: "cron.run", + metadata: { module: job.module }, + pluginId: job.pluginId, + target: { id: dbCron.id, label: job.name, type: "cron_job" }, + }); await db .update(core_cron) diff --git a/packages/vitnode/src/api/modules/admin/ai/routes/access.route.ts b/packages/vitnode/src/api/modules/admin/ai/routes/access.route.ts index 53892cc97d..f2c2331865 100644 --- a/packages/vitnode/src/api/modules/admin/ai/routes/access.route.ts +++ b/packages/vitnode/src/api/modules/admin/ai/routes/access.route.ts @@ -3,6 +3,8 @@ import { z } from "zod"; import { buildRoute } from "@/api/builders"; import { formatDecimal, parseDecimal } from "@/api/lib/ai/decimal"; +import { roleAuditLabel, userAuditLabel } from "@/api/lib/audit/labels"; +import { writeAuditLog } from "@/api/lib/audit/write"; import { core_ai_role_permissions, core_ai_role_policies, @@ -278,6 +280,28 @@ export const updateAiRoleAccessAdminRoute = buildRoute({ ], }); } + + await writeAuditLog( + c, + { + action: "ai_access.role_updated", + metadata: { + grants: grants.map(grant => ({ + dailyLimit: grant.dailyLimit, + granted: grant.granted, + permission: grant.permission, + })), + monthlyPoints, + unlimited, + }, + target: { + id: roleId, + label: await roleAuditLabel(c, roleId), + type: "role", + }, + }, + tx, + ); }); return c.json({ ok: true as const }, 200); @@ -317,14 +341,25 @@ export const updateAiUserOverrideAdminRoute = buildRoute({ }, handler: async c => { const { userId, ...values } = c.req.valid("json"); - await c - .get("db") - .insert(core_ai_user_overrides) - .values({ userId, ...values }) - .onConflictDoUpdate({ - set: values, - target: core_ai_user_overrides.userId, - }); + const label = await userAuditLabel(c, userId); + await c.get("db").transaction(async tx => { + await tx + .insert(core_ai_user_overrides) + .values({ userId, ...values }) + .onConflictDoUpdate({ + set: values, + target: core_ai_user_overrides.userId, + }); + await writeAuditLog( + c, + { + action: "ai_access.user_override_saved", + metadata: values, + target: { id: userId, label, type: "user" }, + }, + tx, + ); + }); return c.json({ ok: true as const }, 200); }, @@ -348,10 +383,23 @@ export const deleteAiUserOverrideAdminRoute = buildRoute({ }, handler: async c => { const { userId } = c.req.valid("query"); - await c - .get("db") - .delete(core_ai_user_overrides) - .where(eq(core_ai_user_overrides.userId, userId)); + const label = await userAuditLabel(c, userId); + await c.get("db").transaction(async tx => { + const removed = await tx + .delete(core_ai_user_overrides) + .where(eq(core_ai_user_overrides.userId, userId)) + .returning({ userId: core_ai_user_overrides.userId }); + if (removed.length === 0) return; + + await writeAuditLog( + c, + { + action: "ai_access.user_override_removed", + target: { id: userId, label, type: "user" }, + }, + tx, + ); + }); return c.json({ ok: true as const }, 200); }, diff --git a/packages/vitnode/src/api/modules/admin/ai/routes/update-settings.route.ts b/packages/vitnode/src/api/modules/admin/ai/routes/update-settings.route.ts index d872929b96..0cb434ef0f 100644 --- a/packages/vitnode/src/api/modules/admin/ai/routes/update-settings.route.ts +++ b/packages/vitnode/src/api/modules/admin/ai/routes/update-settings.route.ts @@ -1,8 +1,12 @@ +import { eq } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; import { z } from "zod"; import { buildRoute } from "@/api/builders"; import { saveAiSettings } from "@/api/lib/ai/settings-store"; +import { writeAuditLog } from "@/api/lib/audit/write"; +import { core_ai_settings } from "@/database/ai"; +import { auditDiff } from "@/lib/audit/diff"; import { zodAiSettings } from "../schemas"; @@ -37,7 +41,31 @@ export const updateAiSettingsAdminRoute = buildRoute({ }); } - await saveAiSettings(c.get("db"), body); + await c.get("db").transaction(async tx => { + const [before] = await tx + .select() + .from(core_ai_settings) + .where(eq(core_ai_settings.id, 1)) + .limit(1); + await saveAiSettings(tx, body); + + const changes = auditDiff({ + after: body, + before: before ?? null, + include: Object.keys(body) as (keyof typeof body)[], + }); + if (changes) { + await writeAuditLog( + c, + { + action: "settings.updated", + changes, + target: { id: "ai", label: "AI", type: "settings" }, + }, + tx, + ); + } + }); return c.json({ ok: true as const }, 200); }, diff --git a/packages/vitnode/src/api/modules/admin/notifications/routes/danger.route.ts b/packages/vitnode/src/api/modules/admin/notifications/routes/danger.route.ts index f0f1d3383f..213628f9c3 100644 --- a/packages/vitnode/src/api/modules/admin/notifications/routes/danger.route.ts +++ b/packages/vitnode/src/api/modules/admin/notifications/routes/danger.route.ts @@ -1,6 +1,9 @@ +import type { Context } from "hono"; + import { z } from "zod"; import { buildRoute } from "@/api/builders"; +import { writeAuditLogBestEffort } from "@/api/lib/audit/write"; import { cancelQueuedNotificationEmails, deleteAllNotifications, @@ -10,6 +13,14 @@ import { resumeNotifications, } from "@/api/models/notifications/danger"; +/** The danger-zone operations act on every member at once, so each is recorded. */ +const auditDanger = async (c: Context, operation: string): Promise => { + await writeAuditLogBestEffort(c, { + action: `notifications.${operation}`, + target: { label: "Notifications", type: "settings", id: "notifications" }, + }); +}; + const canManageNotifications = { module: "notifications", permission: "can_manage", @@ -32,6 +43,7 @@ export const pauseNotificationsRoute = buildRoute({ }, handler: async c => { await pauseNotifications(c); + await auditDanger(c, "paused"); return c.json({ success: true }); }, @@ -49,7 +61,12 @@ export const resumeNotificationsRoute = buildRoute({ "Resumed", ), }, - handler: async c => c.json(await resumeNotifications(c)), + handler: async c => { + const result = await resumeNotifications(c); + await auditDanger(c, "resumed"); + + return c.json(result); + }, }); export const cancelQueuedNotificationEmailsRoute = buildRoute({ @@ -64,7 +81,12 @@ export const cancelQueuedNotificationEmailsRoute = buildRoute({ "Cancelled", ), }, - handler: async c => c.json(await cancelQueuedNotificationEmails(c)), + handler: async c => { + const result = await cancelQueuedNotificationEmails(c); + await auditDanger(c, "emails_cancelled"); + + return c.json(result); + }, }); export const markEverythingReadRoute = buildRoute({ @@ -79,7 +101,12 @@ export const markEverythingReadRoute = buildRoute({ "Marked as read", ), }, - handler: async c => c.json(await markEverythingRead(c)), + handler: async c => { + const result = await markEverythingRead(c); + await auditDanger(c, "all_marked_read"); + + return c.json(result); + }, }); export const deleteAllNotificationsRoute = buildRoute({ @@ -94,7 +121,12 @@ export const deleteAllNotificationsRoute = buildRoute({ "Deleted", ), }, - handler: async c => c.json(await deleteAllNotifications(c)), + handler: async c => { + const result = await deleteAllNotifications(c); + await auditDanger(c, "all_deleted"); + + return c.json(result); + }, }); export const resetMemberNotificationPreferencesRoute = buildRoute({ @@ -106,5 +138,10 @@ export const resetMemberNotificationPreferencesRoute = buildRoute({ path: "/members/reset-preferences", responses: dangerResponses(z.object({ members: z.number() }), "Reset"), }, - handler: async c => c.json(await resetMemberNotificationPreferences(c)), + handler: async c => { + const result = await resetMemberNotificationPreferences(c); + await auditDanger(c, "member_preferences_reset"); + + return c.json(result); + }, }); diff --git a/packages/vitnode/src/api/modules/admin/report-reasons/routes/report-reasons.routes.ts b/packages/vitnode/src/api/modules/admin/report-reasons/routes/report-reasons.routes.ts index 96dc39595e..a0e88c3609 100644 --- a/packages/vitnode/src/api/modules/admin/report-reasons/routes/report-reasons.routes.ts +++ b/packages/vitnode/src/api/modules/admin/report-reasons/routes/report-reasons.routes.ts @@ -5,6 +5,8 @@ import { and, count, eq, inArray, max, sql } from "drizzle-orm"; import { randomBytes } from "node:crypto"; import { buildRoute } from "@/api/builders"; +import { multiLangAuditLabel } from "@/api/lib/audit/labels"; +import { writeAuditLog } from "@/api/lib/audit/write"; import { listReportReasons, REPORT_REASON_WORDS, @@ -14,6 +16,7 @@ import { saveLanguageWords } from "@/api/lib/save-language-words"; import { errorSchema, json } from "@/api/modules/moderation/lib/schema"; import { core_languages_words } from "@/database/languages"; import { core_moderation_report_reasons } from "@/database/moderation"; +import { auditDiff } from "@/lib/audit/diff"; import { MODERATION_LIMITS } from "@/lib/moderation/const"; const titleSchema = z @@ -80,6 +83,7 @@ const findReasonRow = async (c: Context, id: number) => { .select({ code: core_moderation_report_reasons.code, id: core_moderation_report_reasons.id, + requiresDetails: core_moderation_report_reasons.requiresDetails, }) .from(core_moderation_report_reasons) .where(eq(core_moderation_report_reasons.id, id)) @@ -149,6 +153,26 @@ export const createReportReasonAdminRoute = buildRoute({ }) .returning({ id: core_moderation_report_reasons.id }); + await writeAuditLog( + c, + { + action: "report_reason.created", + changes: auditDiff({ + after: { + requiresDetails: body.requiresDetails, + title: multiLangAuditLabel(body.title), + }, + before: null, + }), + target: { + id: row.id, + label: multiLangAuditLabel(body.title), + type: "report_reason", + }, + }, + tx, + ); + return row; }); if (!created) return c.json({ error: "limit_reached" }, 409); @@ -188,13 +212,37 @@ export const updateReportReasonAdminRoute = buildRoute({ const row = await findReasonRow(c, id); if (!row) return c.json({ error: "not_found" }, 404); - if (body.requiresDetails !== undefined) { - await c - .get("db") - .update(core_moderation_report_reasons) - .set({ requiresDetails: body.requiresDetails }) - .where(eq(core_moderation_report_reasons.id, id)); - } + await c.get("db").transaction(async tx => { + if (body.requiresDetails !== undefined) { + await tx + .update(core_moderation_report_reasons) + .set({ requiresDetails: body.requiresDetails }) + .where(eq(core_moderation_report_reasons.id, id)); + } + await writeAuditLog( + c, + { + action: "report_reason.updated", + changes: auditDiff({ + after: { + requiresDetails: body.requiresDetails ?? row.requiresDetails, + ...(body.title === undefined + ? {} + : { title: multiLangAuditLabel(body.title) }), + }, + before: { requiresDetails: row.requiresDetails }, + }), + metadata: + body.description === undefined ? {} : { descriptionChanged: true }, + target: { + id, + label: multiLangAuditLabel(body.title) ?? row.code, + type: "report_reason", + }, + }, + tx, + ); + }); await saveReasonWords(c, id, body); await c.get("events").emit("moderation.report_reason.updated", { code: row.code, @@ -244,6 +292,14 @@ export const deleteReportReasonAdminRoute = buildRoute({ await tx .delete(core_moderation_report_reasons) .where(eq(core_moderation_report_reasons.id, id)); + await writeAuditLog( + c, + { + action: "report_reason.deleted", + target: { id, label: row.code, type: "report_reason" }, + }, + tx, + ); return true; }); @@ -303,10 +359,21 @@ export const reorderReportReasonsAdminRoute = buildRoute({ ), sql` `, ); - await db - .update(core_moderation_report_reasons) - .set({ position: sql`case ${positionById} end` }) - .where(inArray(core_moderation_report_reasons.id, ids)); + await db.transaction(async tx => { + await tx + .update(core_moderation_report_reasons) + .set({ position: sql`case ${positionById} end` }) + .where(inArray(core_moderation_report_reasons.id, ids)); + await writeAuditLog( + c, + { + action: "report_reason.reordered", + metadata: { order: ids }, + target: { type: "report_reason" }, + }, + tx, + ); + }); await c .get("events") .emit("moderation.report_reason.reordered", { reasonIds: ids }); diff --git a/packages/vitnode/src/api/modules/admin/roles/routes/create.route.test.ts b/packages/vitnode/src/api/modules/admin/roles/routes/create.route.test.ts new file mode 100644 index 0000000000..b6cce4489b --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/roles/routes/create.route.test.ts @@ -0,0 +1,114 @@ +import type { Table } from "drizzle-orm"; +import type { Context } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { describe, expect, it, vi } from "vitest"; + +import { core_audit_logs } from "@/database/audit"; +import { core_languages_words } from "@/database/languages"; +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 { createRoleAdminRoute } from "./create.route"; + +const CALLER = { id: 1, name: "Root", roleId: 3 }; + +type MemoryDb = ReturnType["db"]; + +/** The memory db, with every audit insert inside a transaction failing. */ +const failingAuditDb = (db: MemoryDb): MemoryDb => ({ + ...db, + transaction: async run => + await db.transaction(async tx => { + const inner = tx as MemoryDb; + + return await run({ + ...inner, + insert: (table: Table) => { + if (table === core_audit_logs) throw new Error("audit write failed"); + + return inner.insert(table); + }, + }); + }), +}); + +const harness = async ({ failAudit = false } = {}) => { + const cache = createTestCache(); + await grantStaffPermissions(cache, { + permissions: ROOT_STAFF_PERMISSIONS, + userId: CALLER.id, + }); + const memory = createMemoryDb([ + [core_roles, []], + [core_users, [CALLER]], + [core_languages_words, []], + ]); + const db = failAudit ? failingAuditDb(memory.db) : memory.db; + + 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", db as unknown as Context["var"]["db"]); + c.set("events", { + emit: vi.fn(async () => Promise.resolve()), + } as unknown as Context["var"]["events"]); + await next(); + }); + app.openapi(createRoleAdminRoute.route, createRoleAdminRoute.handler); + + const create = async () => + await app.request("/create", { + body: JSON.stringify({ + color: "#0a0", + name: [{ languageCode: "en", value: "Editors" }], + }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + + return { create, memory }; +}; + +describe("POST /admin/roles/create - audit", () => { + it("records role.created with the new role's values", async () => { + const h = await harness(); + + const res = await h.create(); + const { id } = (await res.json()) as { id: number }; + + expect(res.status).toBe(201); + expect(h.memory.rows(core_audit_logs)).toEqual([ + expect.objectContaining({ + action: "role.created", + actorId: CALLER.id, + actorType: "staff", + area: "admin", + changes: expect.objectContaining({ + color: { from: null, to: "#0a0" }, + name: { from: null, to: "Editors" }, + }), + targetId: String(id), + targetLabel: "Editors", + targetType: "role", + }), + ]); + }); + + it("creates no role when its audit entry cannot be written", async () => { + const h = await harness({ failAudit: true }); + + const res = await h.create(); + + expect(res.status).toBe(500); + expect(h.memory.rows(core_roles)).toEqual([]); + expect(h.memory.rows(core_audit_logs)).toEqual([]); + }); +}); diff --git a/packages/vitnode/src/api/modules/admin/roles/routes/create.route.ts b/packages/vitnode/src/api/modules/admin/roles/routes/create.route.ts index 780c46594a..e02ac62ded 100644 --- a/packages/vitnode/src/api/modules/admin/roles/routes/create.route.ts +++ b/packages/vitnode/src/api/modules/admin/roles/routes/create.route.ts @@ -1,8 +1,11 @@ import { z } from "@hono/zod-openapi"; import { buildRoute } from "@/api/builders"; +import { multiLangAuditLabel } from "@/api/lib/audit/labels"; +import { writeAuditLog } from "@/api/lib/audit/write"; import { saveLanguageWords } from "@/api/lib/save-language-words"; import { core_roles } from "@/database/roles"; +import { auditDiff } from "@/lib/audit/diff"; import { EMOJI_ICON_MAX_LENGTH, parseEmojiIcon, @@ -107,29 +110,50 @@ export const createRoleAdminRoute = buildRoute({ maxCoverSize, } = c.req.valid("json"); - const [role] = await c - .get("db") - .insert(core_roles) - .values({ - color: color?.trim() ? color : null, - prefix: serializeEmojiIcon(parseEmojiIcon(prefix)) || null, - allowUploadFiles: allowUploadFiles ?? false, - totalMaxStorage: totalMaxStorage ?? null, - maxStorageForSubmit: maxStorageForSubmit ?? null, - ...(allowUploadAvatar === undefined ? {} : { allowUploadAvatar }), - ...(allowEditPersonalInfo === undefined - ? {} - : { allowEditPersonalInfo }), - ...(allowEditOwnContent === undefined ? {} : { allowEditOwnContent }), - editOwnContentMinutes: editOwnContentMinutes ?? null, - allowDeleteOwnContent: allowDeleteOwnContent ?? false, - allowHideOwnContent: allowHideOwnContent ?? false, - ...(maxAvatarSize === undefined ? {} : { maxAvatarSize }), - ...(allowUploadCover === undefined ? {} : { allowUploadCover }), - ...(maxCoverSize === undefined ? {} : { maxCoverSize }), - updatedAt: new Date(), - }) - .returning({ id: core_roles.id }); + const role = await c.get("db").transaction(async tx => { + const [created] = await tx + .insert(core_roles) + .values({ + color: color?.trim() ? color : null, + prefix: serializeEmojiIcon(parseEmojiIcon(prefix)) || null, + allowUploadFiles: allowUploadFiles ?? false, + totalMaxStorage: totalMaxStorage ?? null, + maxStorageForSubmit: maxStorageForSubmit ?? null, + ...(allowUploadAvatar === undefined ? {} : { allowUploadAvatar }), + ...(allowEditPersonalInfo === undefined + ? {} + : { allowEditPersonalInfo }), + ...(allowEditOwnContent === undefined ? {} : { allowEditOwnContent }), + editOwnContentMinutes: editOwnContentMinutes ?? null, + allowDeleteOwnContent: allowDeleteOwnContent ?? false, + allowHideOwnContent: allowHideOwnContent ?? false, + ...(maxAvatarSize === undefined ? {} : { maxAvatarSize }), + ...(allowUploadCover === undefined ? {} : { allowUploadCover }), + ...(maxCoverSize === undefined ? {} : { maxCoverSize }), + updatedAt: new Date(), + }) + .returning(); + + await writeAuditLog( + c, + { + action: "role.created", + changes: auditDiff({ + after: { ...created, name: multiLangAuditLabel(name) }, + before: null, + exclude: ["createdAt", "id", "updatedAt"], + }), + target: { + id: created.id, + label: multiLangAuditLabel(name), + type: "role", + }, + }, + tx, + ); + + return created; + }); await saveLanguageWords(c, { pluginCode: "core", diff --git a/packages/vitnode/src/api/modules/admin/roles/routes/delete.route.ts b/packages/vitnode/src/api/modules/admin/roles/routes/delete.route.ts index e88620942a..6a25702c9d 100644 --- a/packages/vitnode/src/api/modules/admin/roles/routes/delete.route.ts +++ b/packages/vitnode/src/api/modules/admin/roles/routes/delete.route.ts @@ -2,6 +2,8 @@ import { z } from "@hono/zod-openapi"; import { and, count, eq, inArray } from "drizzle-orm"; import { buildRoute } from "@/api/builders"; +import { roleAuditLabel } from "@/api/lib/audit/labels"; +import { writeAuditLog } from "@/api/lib/audit/write"; import { invalidateAllStaffPermissions } from "@/api/lib/staff-permission-cache"; import { invalidateSessionCacheForUser } from "@/api/models/session-revoke"; import { assertCanAssignPrimaryRole } from "@/api/modules/admin/users/lib/assert-edit-user-permission"; @@ -132,6 +134,8 @@ export const deleteRoleAdminRoute = buildRoute({ await assertCanAssignPrimaryRole(c, targetRoleId); } + const label = await roleAuditLabel(c, roleId); + const affectedUserIds = await db.transaction(async tx => { const members = await tx .select({ id: core_users.id }) @@ -178,6 +182,19 @@ export const deleteRoleAdminRoute = buildRoute({ await tx.delete(core_roles).where(eq(core_roles.id, roleId)); + await writeAuditLog( + c, + { + action: "role.deleted", + metadata: { + movedMembers: memberIds.length, + ...(targetRoleId === null ? {} : { movedToRoleId: targetRoleId }), + }, + target: { id: roleId, label, type: "role" }, + }, + tx, + ); + return [ ...new Set([ ...memberIds, diff --git a/packages/vitnode/src/api/modules/admin/roles/routes/update.route.ts b/packages/vitnode/src/api/modules/admin/roles/routes/update.route.ts index af5660bf27..751a305b62 100644 --- a/packages/vitnode/src/api/modules/admin/roles/routes/update.route.ts +++ b/packages/vitnode/src/api/modules/admin/roles/routes/update.route.ts @@ -2,9 +2,12 @@ import { z } from "@hono/zod-openapi"; import { eq } from "drizzle-orm"; import { buildRoute } from "@/api/builders"; +import { multiLangAuditLabel, roleAuditLabel } from "@/api/lib/audit/labels"; +import { writeAuditLog } from "@/api/lib/audit/write"; import { saveLanguageWords } from "@/api/lib/save-language-words"; import { invalidateAllStaffPermissions } from "@/api/lib/staff-permission-cache"; import { core_roles } from "@/database/roles"; +import { auditDiff } from "@/lib/audit/diff"; import { parseEmojiIcon, serializeEmojiIcon } from "@/lib/emoji-icon"; import { assertCanManageAdminRole } from "../lib/assert-manage-admin-role"; @@ -91,7 +94,7 @@ export const updateRoleAdminRoute = buildRoute({ } const [role] = await db - .select({ id: core_roles.id }) + .select() .from(core_roles) .where(eq(core_roles.id, roleId)) .limit(1); @@ -151,7 +154,30 @@ export const updateRoleAdminRoute = buildRoute({ values.maxCoverSize = body.maxCoverSize; } - await db.update(core_roles).set(values).where(eq(core_roles.id, roleId)); + const label = await roleAuditLabel(c, roleId); + const renamed = + body.name === undefined ? label : multiLangAuditLabel(body.name); + + await db.transaction(async tx => { + await tx.update(core_roles).set(values).where(eq(core_roles.id, roleId)); + + const changes = auditDiff({ + after: { ...role, ...values, name: renamed }, + before: { ...role, name: label }, + exclude: ["createdAt", "id", "updatedAt"], + }); + if (changes) { + await writeAuditLog( + c, + { + action: "role.updated", + changes, + target: { id: roleId, label: renamed, type: "role" }, + }, + tx, + ); + } + }); if (body.name !== undefined) { await saveLanguageWords(c, { diff --git a/packages/vitnode/src/api/modules/admin/staff/routes/create.route.ts b/packages/vitnode/src/api/modules/admin/staff/routes/create.route.ts index 1bd421e10e..b31ec09e40 100644 --- a/packages/vitnode/src/api/modules/admin/staff/routes/create.route.ts +++ b/packages/vitnode/src/api/modules/admin/staff/routes/create.route.ts @@ -2,6 +2,8 @@ import { z } from "@hono/zod-openapi"; import { eq, or } from "drizzle-orm"; import { buildRoute } from "@/api/builders"; +import { staffEntryAuditLabel } from "@/api/lib/audit/labels"; +import { writeAuditLog } from "@/api/lib/audit/write"; import { assertStaffPermission } from "@/api/lib/check-staff-permission"; import { invalidateStaffEntry } from "@/api/lib/staff-permission-cache"; import { core_admin_permissions } from "@/database/admins"; @@ -112,14 +114,28 @@ export const createStaffAdminRoute = buildRoute({ return c.json({ error: "Staff entry already exists" }, 409); } - const [created] = await c - .get("db") - .insert(table) - .values({ - roleId: roleId ?? null, - userId: userId ?? null, - }) - .returning({ id: table.id }); + const label = await staffEntryAuditLabel(c, { roleId, userId }); + const created = await c.get("db").transaction(async tx => { + const [inserted] = await tx + .insert(table) + .values({ + roleId: roleId ?? null, + userId: userId ?? null, + }) + .returning({ id: table.id }); + + await writeAuditLog( + c, + { + action: `${type}_permissions.created`, + metadata: roleId ? { roleId } : { userId }, + target: { id: inserted.id, label, type: "staff_entry" }, + }, + tx, + ); + + return inserted; + }); await invalidateStaffEntry(c, { roleId, userId }); diff --git a/packages/vitnode/src/api/modules/admin/staff/routes/delete.route.ts b/packages/vitnode/src/api/modules/admin/staff/routes/delete.route.ts index 68185a7aef..76efeb4aee 100644 --- a/packages/vitnode/src/api/modules/admin/staff/routes/delete.route.ts +++ b/packages/vitnode/src/api/modules/admin/staff/routes/delete.route.ts @@ -3,6 +3,11 @@ import { eq } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; import { buildRoute } from "@/api/builders"; +import { + staffEntryAuditLabel, + staffGrantsAuditDelta, +} from "@/api/lib/audit/labels"; +import { writeAuditLog } from "@/api/lib/audit/write"; import { assertStaffPermission } from "@/api/lib/check-staff-permission"; import { invalidateStaffEntry } from "@/api/lib/staff-permission-cache"; import { core_admin_permissions } from "@/database/admins"; @@ -93,7 +98,26 @@ export const deleteStaffAdminRoute = buildRoute({ ); await assertCanManageStaffEntry(c, { type, entry }); - await c.get("db").delete(table).where(eq(table.id, entryId)); + const label = await staffEntryAuditLabel(c, entry); + await c.get("db").transaction(async tx => { + await tx.delete(table).where(eq(table.id, entryId)); + await writeAuditLog( + c, + { + action: `${type}_permissions.deleted`, + metadata: { + revoked: entry.unrestricted + ? ["*"] + : staffGrantsAuditDelta(entry.permissions, []).revoked, + ...(entry.roleId === null + ? { userId: entry.userId } + : { roleId: entry.roleId }), + }, + target: { id: entryId, label, type: "staff_entry" }, + }, + tx, + ); + }); await invalidateStaffEntry(c, entry); diff --git a/packages/vitnode/src/api/modules/admin/staff/routes/staff-entry-routes.test.ts b/packages/vitnode/src/api/modules/admin/staff/routes/staff-entry-routes.test.ts index fb14187bac..6636563f73 100644 --- a/packages/vitnode/src/api/modules/admin/staff/routes/staff-entry-routes.test.ts +++ b/packages/vitnode/src/api/modules/admin/staff/routes/staff-entry-routes.test.ts @@ -12,6 +12,7 @@ import type { buildRoute } from "@/api/lib/route"; import { normalizePermissionStaffModules } from "@/api/lib/permission-staff"; import { core_admin_permissions } from "@/database/admins"; +import { core_audit_logs } from "@/database/audit"; import { core_moderators_permissions } from "@/database/moderators"; import { core_roles } from "@/database/roles"; import { core_users_secondary_roles } from "@/database/users"; @@ -160,7 +161,9 @@ const appFor = async ( const storedEntries = (type: PermissionStaffType = "admin") => memory.rows(tableByType[type]); - return { app, storedEntries }; + const auditEntries = () => memory.rows(core_audit_logs); + + return { app, auditEntries, storedEntries }; }; const patch = async ( @@ -405,6 +408,57 @@ describe("updating a staff entry", () => { const remove = async (app: OpenAPIHono, type: PermissionStaffType = "admin") => await app.request(`/entry/${type}/5`, { method: "DELETE" }); +describe("auditing a staff entry update", () => { + it("records the granted and revoked grants, never the whole list", async () => { + const { app, auditEntries } = await appFor( + updatePermissionsStaffAdminRoute, + { + entry: entryFor({ permissions: [VIEW_ADMINS, DELETE_ADMINS] }), + grants: { admin: MANAGE_ADMINS }, + }, + ); + + const res = await patch(app, { + permissions: [VIEW_ADMINS, CREATE_ADMINS], + unrestricted: false, + }); + + expect(res.status).toBe(200); + expect(auditEntries()).toEqual([ + expect.objectContaining({ + action: "admin_permissions.updated", + actorId: CALLER.id, + changes: null, + metadata: { + granted: [`${CORE}:staff_admins:can_create`], + revoked: [`${CORE}:staff_admins:can_delete`], + roleId: TARGET_ROLE_ID, + }, + targetId: String(entryFor().id), + targetType: "staff_entry", + }), + ]); + }); + + it("records nothing when the grants did not move", async () => { + const { app, auditEntries } = await appFor( + updatePermissionsStaffAdminRoute, + { + entry: entryFor({ permissions: [VIEW_ADMINS] }), + grants: { admin: MANAGE_ADMINS }, + }, + ); + + const res = await patch(app, { + permissions: [VIEW_ADMINS], + unrestricted: false, + }); + + expect(res.status).toBe(200); + expect(auditEntries()).toEqual([]); + }); +}); + describe("removing a staff entry", () => { it("removes an entry within the caller's own permissions", async () => { const { app, storedEntries } = await appFor(deleteStaffAdminRoute, { 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 d0c8b3fa4b..ebf9236d08 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 @@ -5,10 +5,16 @@ import { HTTPException } from "hono/http-exception"; import type { PermissionsStaffArgs } from "@/api/lib/permission-staff"; import { buildRoute } from "@/api/builders"; +import { + staffEntryAuditLabel, + staffGrantsAuditDelta, +} from "@/api/lib/audit/labels"; +import { writeAuditLog } from "@/api/lib/audit/write"; import { assertStaffPermission } from "@/api/lib/check-staff-permission"; import { invalidateStaffEntry } from "@/api/lib/staff-permission-cache"; import { core_admin_permissions } from "@/database/admins"; import { core_moderators_permissions } from "@/database/moderators"; +import { auditDiff } from "@/lib/audit/diff"; import { manifest } from "@/manifest"; import { @@ -149,12 +155,42 @@ export const updatePermissionsStaffAdminRoute = buildRoute({ ? [] : await sanitizeStaffGrants(c, { permissions, type }); - const [updated] = await c - .get("db") - .update(table) - .set({ unrestricted, permissions: sanitized }) - .where(eq(table.id, entryId)) - .returning({ id: table.id }); + const label = await staffEntryAuditLabel(c, entry); + const updated = await c.get("db").transaction(async tx => { + const [row] = await tx + .update(table) + .set({ unrestricted, permissions: sanitized }) + .where(eq(table.id, entryId)) + .returning({ id: table.id }); + if (!row) return null; + + // The grants that moved, never the whole list: what an auditor asks is + // "who was given what, and by whom". + const delta = staffGrantsAuditDelta(entry.permissions, sanitized); + const changes = auditDiff({ + after: { unrestricted }, + before: { unrestricted: entry.unrestricted }, + }); + if (changes || delta.granted.length > 0 || delta.revoked.length > 0) { + await writeAuditLog( + c, + { + action: `${type}_permissions.updated`, + changes, + metadata: { + ...delta, + ...(entry.roleId === null + ? { userId: entry.userId } + : { roleId: entry.roleId }), + }, + target: { id: entryId, label, type: "staff_entry" }, + }, + tx, + ); + } + + return row; + }); if (!updated) { return c.json({ error: "Staff entry not found" }, 404); diff --git a/packages/vitnode/src/api/modules/admin/users/lib/audit-changes.ts b/packages/vitnode/src/api/modules/admin/users/lib/audit-changes.ts new file mode 100644 index 0000000000..d1b4bc8d07 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/lib/audit-changes.ts @@ -0,0 +1,52 @@ +import type { AuditChanges } from "@/lib/audit/const"; + +import { auditDiff } from "@/lib/audit/diff"; + +/** Account fields an audit entry may show the values of: none of them is private. */ +const VISIBLE_FIELDS = [ + "language", + "name", + "nameCode", + "newsletter", + "roleId", + "timeZone", +] as const; + +/** + * The audit changes of an AdminCP edit of a user. Roles, names and preferences + * keep their values; personal data (email, birthday, real name, phone...) is + * recorded only as changed, never with its values - an audit trail is no + * reason to keep a second copy of somebody's address. + */ +export const adminUserAuditChanges = ({ + after, + before, + secondaryRoleIds, +}: { + after: Record; + before: Record; + secondaryRoleIds?: { after: number[]; before: number[] }; +}): AuditChanges | null => { + const visible = VISIBLE_FIELDS.filter(field => field in after); + const changes: AuditChanges = { + ...auditDiff({ + after: Object.fromEntries(visible.map(field => [field, after[field]])), + before: Object.fromEntries(visible.map(field => [field, before[field]])), + }), + }; + + for (const field of Object.keys(after)) { + if ((VISIBLE_FIELDS as readonly string[]).includes(field)) continue; + changes[field] = { redacted: true }; + } + + if (secondaryRoleIds) { + const from = [...secondaryRoleIds.before].sort((a, b) => a - b); + const to = [...secondaryRoleIds.after].sort((a, b) => a - b); + if (from.join(",") !== to.join(",")) { + changes.secondaryRoleIds = { from, to }; + } + } + + return Object.keys(changes).length > 0 ? changes : null; +}; diff --git a/packages/vitnode/src/api/modules/admin/users/routes/password.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/password.route.ts index c7250761f3..65838d25e2 100644 --- a/packages/vitnode/src/api/modules/admin/users/routes/password.route.ts +++ b/packages/vitnode/src/api/modules/admin/users/routes/password.route.ts @@ -2,6 +2,7 @@ import { z } from "@hono/zod-openapi"; import { eq } from "drizzle-orm"; import { buildRoute } from "@/api/builders"; +import { writeAuditLog } from "@/api/lib/audit/write"; import { assertPasswordSignInEnabled } from "@/api/lib/password-sign-in"; import { revokeAccountRecoveryTokens } from "@/api/lib/recovery-tokens"; import { PasswordModel } from "@/api/models/password"; @@ -76,7 +77,7 @@ export const setPasswordUserAdminRoute = buildRoute({ const db = c.get("db"); const [user] = await db - .select({ id: core_users.id }) + .select({ id: core_users.id, name: core_users.name }) .from(core_users) .where(eq(core_users.id, userId)) .limit(1); @@ -88,10 +89,23 @@ export const setPasswordUserAdminRoute = buildRoute({ await assertCanEditAdminTarget(c, user.id); const hashedPassword = await new PasswordModel().encryptPassword(password); - await db - .update(core_users) - .set({ password: hashedPassword }) - .where(eq(core_users.id, user.id)); + await db.transaction(async tx => { + await tx + .update(core_users) + .set({ password: hashedPassword }) + .where(eq(core_users.id, user.id)); + // That it was reset, and by whom - the password itself is never an + // audit field, hashed or not. + await writeAuditLog( + c, + { + action: "user.password_reset", + changes: { password: { redacted: true } }, + target: { id: user.id, label: user.name, type: "user" }, + }, + tx, + ); + }); await c.get("events").emit("user.password.updated", { userId: user.id }); await revokeAccountRecoveryTokens(c, user.id); diff --git a/packages/vitnode/src/api/modules/admin/users/routes/revoke-device.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/revoke-device.route.ts index 81c60eafef..7862502e8d 100644 --- a/packages/vitnode/src/api/modules/admin/users/routes/revoke-device.route.ts +++ b/packages/vitnode/src/api/modules/admin/users/routes/revoke-device.route.ts @@ -1,6 +1,7 @@ import { z } from "@hono/zod-openapi"; import { buildRoute } from "@/api/builders"; +import { writeAuditLogBestEffort } from "@/api/lib/audit/write"; import { revokeSessions } from "@/api/models/session-revoke"; import { findUserDeviceId } from "@/api/models/user-devices"; @@ -62,6 +63,11 @@ export const revokeUserDeviceAdminRoute = buildRoute({ } await revokeSessions(c, { deviceId, userId }); + await writeAuditLogBestEffort(c, { + action: "user.device_revoked", + metadata: { deviceId }, + target: { id: userId, type: "user" }, + }); return c.json({ ok: true as const }, 200); }, diff --git a/packages/vitnode/src/api/modules/admin/users/routes/revoke-devices.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/revoke-devices.route.ts index 304e851ea9..bfe1178b99 100644 --- a/packages/vitnode/src/api/modules/admin/users/routes/revoke-devices.route.ts +++ b/packages/vitnode/src/api/modules/admin/users/routes/revoke-devices.route.ts @@ -1,6 +1,7 @@ import { z } from "@hono/zod-openapi"; import { buildRoute } from "@/api/builders"; +import { writeAuditLogBestEffort } from "@/api/lib/audit/write"; import { revokeSessions } from "@/api/models/session-revoke"; import { assertCanEditAdminTarget } from "../lib/assert-edit-user-permission"; @@ -44,6 +45,10 @@ export const revokeUserDevicesAdminRoute = buildRoute({ await assertCanEditAdminTarget(c, userId); await revokeSessions(c, { userId }); + await writeAuditLogBestEffort(c, { + action: "user.devices_revoked", + target: { id: userId, type: "user" }, + }); return c.json({ ok: true as const }, 200); }, diff --git a/packages/vitnode/src/api/modules/admin/users/routes/update.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/update.route.ts index a486e06e98..9319847c4e 100644 --- a/packages/vitnode/src/api/modules/admin/users/routes/update.route.ts +++ b/packages/vitnode/src/api/modules/admin/users/routes/update.route.ts @@ -2,6 +2,7 @@ import { z } from "@hono/zod-openapi"; import { and, eq, inArray, ne } from "drizzle-orm"; import { buildRoute } from "@/api/builders"; +import { writeAuditLog } from "@/api/lib/audit/write"; import { isValidTimeZone } from "@/api/lib/notifications/digest-period"; import { revokeAccountRecoveryTokens } from "@/api/lib/recovery-tokens"; import { invalidateStaffPermissionsForUser } from "@/api/lib/staff-permission-cache"; @@ -25,6 +26,7 @@ import { assertCanAssignRoles, assertCanEditAdminTarget, } from "../lib/assert-edit-user-permission"; +import { adminUserAuditChanges } from "../lib/audit-changes"; const nameRegex = /^(?!.* {2})[\p{L}\p{N}._@ -]*$/u; const CALENDAR_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; @@ -173,7 +175,15 @@ export const updateUserAdminRoute = buildRoute({ } const [user] = await db - .select({ id: core_users.id, roleId: core_users.roleId }) + .select({ + id: core_users.id, + language: core_users.language, + name: core_users.name, + nameCode: core_users.nameCode, + newsletter: core_users.newsletter, + roleId: core_users.roleId, + timeZone: core_users.timeZone, + }) .from(core_users) .where(eq(core_users.id, userId)) .limit(1); @@ -309,20 +319,77 @@ export const updateUserAdminRoute = buildRoute({ values.roleId = body.roleId; } - if (secondaryRoleIds !== undefined) { - await db - .delete(core_users_secondary_roles) - .where(eq(core_users_secondary_roles.userId, user.id)); - - if (secondaryRoleIds.length > 0) { - await db.insert(core_users_secondary_roles).values( - secondaryRoleIds.map(roleId => ({ - userId: user.id, - roleId, - })), + const previousSecondaryRoleIds = + secondaryRoleIds === undefined + ? [] + : ( + await db + .select({ roleId: core_users_secondary_roles.roleId }) + .from(core_users_secondary_roles) + .where(eq(core_users_secondary_roles.userId, user.id)) + ).map(row => row.roleId); + + // The role writes, the profile write and their audit entry commit together: + // a role change is exactly what an audit trail is for. + const updated = await db.transaction(async tx => { + if (secondaryRoleIds !== undefined) { + await tx + .delete(core_users_secondary_roles) + .where(eq(core_users_secondary_roles.userId, user.id)); + + if (secondaryRoleIds.length > 0) { + await tx.insert(core_users_secondary_roles).values( + secondaryRoleIds.map(roleId => ({ + userId: user.id, + roleId, + })), + ); + } + } + + const [row] = + Object.keys(values).length > 0 + ? await tx + .update(core_users) + .set(values) + .where(eq(core_users.id, user.id)) + .returning({ + id: core_users.id, + name: core_users.name, + email: core_users.email, + nameCode: core_users.nameCode, + }) + : []; + + const changes = adminUserAuditChanges({ + after: values, + before: user, + secondaryRoleIds: + secondaryRoleIds === undefined + ? undefined + : { after: secondaryRoleIds, before: previousSecondaryRoleIds }, + }); + if (changes) { + await writeAuditLog( + c, + { + action: + changes.roleId || changes.secondaryRoleIds + ? "user.roles_changed" + : "user.updated", + changes, + target: { + id: user.id, + label: row?.name ?? user.name, + type: "user", + }, + }, + tx, ); } - } + + return row ?? null; + }); // A role change moves which staff entries apply to this user, so their // cached permission set has to go. Only this user's - the roles themselves @@ -330,18 +397,7 @@ export const updateUserAdminRoute = buildRoute({ const rolesChanged = body.roleId !== undefined || secondaryRoleIds !== undefined; - if (Object.keys(values).length > 0) { - const [updated] = await db - .update(core_users) - .set(values) - .where(eq(core_users.id, user.id)) - .returning({ - id: core_users.id, - name: core_users.name, - email: core_users.email, - nameCode: core_users.nameCode, - }); - + if (updated) { if (values.email !== undefined) { await revokeAccountRecoveryTokens(c, user.id); } diff --git a/packages/vitnode/src/api/modules/admin/users/routes/verify-email.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/verify-email.route.ts index 569a022817..cfe8fed1b2 100644 --- a/packages/vitnode/src/api/modules/admin/users/routes/verify-email.route.ts +++ b/packages/vitnode/src/api/modules/admin/users/routes/verify-email.route.ts @@ -2,6 +2,7 @@ import { z } from "@hono/zod-openapi"; import { eq } from "drizzle-orm"; import { buildRoute } from "@/api/builders"; +import { writeAuditLog } from "@/api/lib/audit/write"; import { invalidateSessionCacheForUser } from "@/api/models/session-revoke"; import { core_users, core_users_confirm_emails } from "@/database/users"; @@ -66,19 +67,34 @@ export const verifyEmailUserAdminRoute = buildRoute({ await assertCanEditAdminTarget(c, userId); - const [updated] = await db - .update(core_users) - .set({ emailVerified: true }) - .where(eq(core_users.id, user.id)) - .returning({ - email: core_users.email, - name: core_users.name, - emailVerified: core_users.emailVerified, - }); + const updated = await db.transaction(async tx => { + const [row] = await tx + .update(core_users) + .set({ emailVerified: true }) + .where(eq(core_users.id, user.id)) + .returning({ + email: core_users.email, + name: core_users.name, + emailVerified: core_users.emailVerified, + }); - await db - .delete(core_users_confirm_emails) - .where(eq(core_users_confirm_emails.userId, user.id)); + await tx + .delete(core_users_confirm_emails) + .where(eq(core_users_confirm_emails.userId, user.id)); + + if (!user.emailVerified) { + await writeAuditLog( + c, + { + action: "user.email_verified", + target: { id: user.id, label: row.name, type: "user" }, + }, + tx, + ); + } + + return row; + }); if (!user.emailVerified) { await invalidateSessionCacheForUser(c, user.id); await c diff --git a/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.test.ts b/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.test.ts index 2ba4d23c59..9ef5fd502f 100644 --- a/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.test.ts +++ b/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.test.ts @@ -5,7 +5,12 @@ import { getTableName } from "drizzle-orm"; import { PgDialect } from "drizzle-orm/pg-core"; import { describe, expect, it, vi } from "vitest"; +import type { ContentActor } from "@/content/revisions"; +import type { ContentDatabase } from "@/content/server/service"; + +import { recordContentAudit } from "@/content/server/audit"; import { core_content_schedules } from "@/database/content"; +import { createAuditRecorder } from "@/tests/audit-recorder"; import { testEditorialPostContentType } from "@/tests/content-fixtures"; import { executeContentSchedule } from "./execute-content-schedule"; @@ -106,7 +111,10 @@ const harness = ({ const settlements: Settlement[] = []; let committed = false; + const audit = createAuditRecorder(); + const handleFor = (handle: Handle) => ({ + insert: (table: unknown) => audit.insert(table), select: () => ({ from: (table: typeof core_content_schedules) => ({ where: (condition: SQL | undefined) => ({ @@ -171,6 +179,7 @@ const harness = ({ } as unknown as Context; return { + audits: audit.entries, c, claims, committed: () => committed, @@ -289,6 +298,46 @@ describe("executeContentSchedule", () => { }); }); + it("audits the publish as the system, naming who booked it", async () => { + const { audits, c, publish } = harness(); + // What the editorial capture does: record the transition in its own + // transaction, as the actor it was given. + publish.mockImplementation( + async ( + itemId: number, + options: { actor: ContentActor; tx: ContentDatabase }, + ) => { + await recordContentAudit(c, options.tx, { + action: "published", + actor: options.actor, + definition: testEditorialPostContentType, + itemId, + pluginId: PLUGIN_ID, + }); + + return outcome; + }, + ); + + await executeContentSchedule(c, { generation: 1, scheduleId: 55 }); + + expect(audits).toEqual([ + expect.objectContaining({ + action: "content.published", + actorId: null, + actorType: "system", + area: "system", + correlationId: "schedule:55", + ipAddress: null, + metadata: { + scheduleId: 55, + scheduledByUserId: 3, + trigger: "scheduled_task", + }, + }), + ]); + }); + describe("the effects payload", () => { it("names the person who booked it", async () => { // The actor is genuinely the system, so "on whose instruction" has to diff --git a/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.ts b/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.ts index 517f094577..c151b92767 100644 --- a/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.ts +++ b/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.ts @@ -5,6 +5,7 @@ import type { ContentEditorialOutcome } from "@/content/server/editorial-service import type { ContentScheduleEffectsPayload } from "@/content/server/schedule-effects"; import type { AnyContentTypeDefinition } from "@/content/types"; +import { AUDIT_SYSTEM_ACTOR, withAuditContext } from "@/api/lib/audit/context"; import { CONTENT_QUEUE_TASK_SCHEDULE_EFFECTS } from "@/content/const"; import { contentIdFromKey } from "@/content/ids"; import { CONTENT_SYSTEM_ACTOR } from "@/content/server/actor"; @@ -176,14 +177,30 @@ export const executeContentSchedule = async ( return { kind: "skipped", reason: "not an identifier of this type" }; } - const outcome = await editorialService(c, { pluginId })[claimed.action]( - itemId, + // Executed by the system, and audited as such: whoever booked the + // schedule did not press the button now. Their id, the schedule and the + // trigger ride along in the entry's metadata. The entry is written in this + // transaction, so a rolled-back or retried run leaves none, and the claim + // above means a settled schedule never runs - or is audited - twice. + const outcome = await withAuditContext( + c, { - // No fake user id anywhere. Who *asked* for this is on the schedule - // row and travels in the event as `scheduledBy`. - actor: CONTENT_SYSTEM_ACTOR, - tx, + actor: AUDIT_SYSTEM_ACTOR, + area: "system", + correlationId: `schedule:${claimed.id}`, + metadata: { + scheduledByUserId: claimed.createdBy, + scheduleId: claimed.id, + trigger: "scheduled_task", + }, }, + async () => + await editorialService(c, { pluginId })[claimed.action](itemId, { + // No fake user id anywhere. Who *asked* for this is on the schedule + // row and travels in the event as `scheduledBy`. + actor: CONTENT_SYSTEM_ACTOR, + tx, + }), ); // Settled whatever happened. A record that was deleted first, or is diff --git a/packages/vitnode/src/api/modules/cron/cron.module.ts b/packages/vitnode/src/api/modules/cron/cron.module.ts index cc3dda7b72..1e4d0dc384 100644 --- a/packages/vitnode/src/api/modules/cron/cron.module.ts +++ b/packages/vitnode/src/api/modules/cron/cron.module.ts @@ -1,5 +1,6 @@ import { buildModule } from "@/api/builders"; +import { auditRetentionCron } from "./cron/audit-retention.cron"; import { checkUpdatesCron } from "./cron/check-updates.cron"; import { cleanCron } from "./cron/clean.cron"; import { runCronRoute } from "./routes/cron.route"; @@ -7,5 +8,5 @@ import { runCronRoute } from "./routes/cron.route"; export const cronModule = buildModule({ name: "cron", routes: [runCronRoute], - cronJobs: [cleanCron, checkUpdatesCron], + cronJobs: [cleanCron, checkUpdatesCron, auditRetentionCron], }); diff --git a/packages/vitnode/src/api/modules/cron/cron/audit-retention.cron.ts b/packages/vitnode/src/api/modules/cron/cron/audit-retention.cron.ts new file mode 100644 index 0000000000..574c9e0b12 --- /dev/null +++ b/packages/vitnode/src/api/modules/cron/cron/audit-retention.cron.ts @@ -0,0 +1,23 @@ +import { pruneAuditLogs } from "@/api/lib/audit/retention"; +import { loadAuditSettings } from "@/api/lib/audit/settings"; +import { writeAuditLog } from "@/api/lib/audit/write"; +import { buildCron } from "@/api/lib/cron"; + +export const auditRetentionCron = buildCron({ + name: "audit-retention", + description: + "Delete Activity Logs entries older than the retention period set in the AdminCP", + schedule: "40 3 * * *", + handler: async c => { + const { retentionDays } = await loadAuditSettings(c.get("db")); + const deleted = await pruneAuditLogs(c.get("db"), { retentionDays }); + if (deleted === 0) return; + + // The one deletion the log ever sees, and it says so. + await writeAuditLog(c, { + action: "audit_logs.pruned", + metadata: { deleted, retentionDays }, + target: { label: "Activity Logs", type: "settings", id: "activity_logs" }, + }); + }, +}); diff --git a/packages/vitnode/src/api/modules/cron/routes/cron.route.ts b/packages/vitnode/src/api/modules/cron/routes/cron.route.ts index 5e70674ffc..44f910be05 100644 --- a/packages/vitnode/src/api/modules/cron/routes/cron.route.ts +++ b/packages/vitnode/src/api/modules/cron/routes/cron.route.ts @@ -2,6 +2,7 @@ import { and, eq, isNull } from "drizzle-orm"; import { z } from "zod"; import { buildRoute } from "@/api/builders"; +import { AUDIT_SYSTEM_ACTOR, withAuditContext } from "@/api/lib/audit/context"; import { cronAuthMiddleware } from "@/api/middlewares/cron-auth.middleware"; import { core_cron } from "@/database/cron"; import { getNextCronRunDate } from "@/lib/api/get-next-cron-run-date"; @@ -117,7 +118,17 @@ export const runCronRoute = buildRoute({ } try { - await job.handler(c); + await withAuditContext( + c, + { + actor: AUDIT_SYSTEM_ACTOR, + area: "system", + metadata: { cronJob: job.name, trigger: "cron" }, + }, + async () => { + await job.handler(c); + }, + ); return { success: true, jobName: job.name }; } catch (error) { diff --git a/packages/vitnode/src/api/modules/moderation/lib/audit.ts b/packages/vitnode/src/api/modules/moderation/lib/audit.ts new file mode 100644 index 0000000000..837cefe397 --- /dev/null +++ b/packages/vitnode/src/api/modules/moderation/lib/audit.ts @@ -0,0 +1,91 @@ +import type { Context } from "hono"; + +import type { ContentDatabase } from "@/content/server/service"; +import type { AuditChanges } from "@/lib/audit/const"; + +import { withAuditContext } from "@/api/lib/audit/context"; +import { writeAuditLog } from "@/api/lib/audit/write"; + +import type { ReportRow } from "./reports"; + +type AuditedReport = Pick< + ReportRow, + | "id" + | "pluginId" + | "scopeFamily" + | "scopeId" + | "targetId" + | "targetTitle" + | "targetType" +>; + +const scopeOf = (report: AuditedReport) => + report.scopeFamily && report.scopeId + ? { family: report.scopeFamily, id: report.scopeId } + : null; + +/** + * Runs one moderator operation on a report: every audit entry written inside - + * the report's own and any its action causes, such as hiding the reported + * post - is attributed to the ModeratorCP and to the report's moderation + * scope, so a moderator of that scope (and only of that scope) sees it. + */ +export const withReportAudit = async ( + c: Context, + report: AuditedReport, + body: () => Promise, +): Promise => + await withAuditContext( + c, + { + area: "moderator", + metadata: { reportId: report.id }, + scope: scopeOf(report), + }, + body, + ); + +/** + * The global audit entry of a report workflow step, written in the same + * transaction as the report update and its own timeline event. The report's + * timeline (`core_moderation_report_events`) stays the detailed history; this + * entry is what a cross-plugin activity view lists. + */ +export const recordReportAudit = async ( + c: Context, + tx: ContentDatabase, + report: AuditedReport, + { + action, + changes = null, + metadata, + }: { + action: string; + changes?: AuditChanges | null; + metadata?: Record; + }, +): Promise => { + await writeAuditLog( + c, + { + action: `report.${action}`, + area: "moderator", + changes, + metadata: { + reportedPluginId: report.pluginId, + reportedTargetId: report.targetId, + reportedTargetType: report.targetType, + ...metadata, + }, + scope: scopeOf(report), + target: { + id: report.id, + label: report.targetTitle + ? `#${report.id} ${report.targetTitle}` + : `#${report.id}`, + type: "report", + }, + }, + tx, + ); +}; diff --git a/packages/vitnode/src/api/modules/moderation/reports.test.ts b/packages/vitnode/src/api/modules/moderation/reports.test.ts index 044a8fbe1b..5ad6cf4d50 100644 --- a/packages/vitnode/src/api/modules/moderation/reports.test.ts +++ b/packages/vitnode/src/api/modules/moderation/reports.test.ts @@ -7,6 +7,7 @@ import type { PermissionsStaffArgs } from "@/api/lib/permission-staff"; import type { ModerationCaptureResult } from "@/api/moderation"; import { defineModerationScope, defineReportTarget } from "@/api/moderation"; +import { core_audit_logs } from "@/database/audit"; import { core_files } from "@/database/files"; import { core_moderation_report_events, @@ -553,6 +554,39 @@ describe("working a report", () => { ); }); + it("records a claim in the activity log next to the report's own timeline", async () => { + const h = await harness(); + const id = await h.reportId(REPORTER, "1"); + + const res = await h.call(MOD_A, "POST", `/reports/${id}/claim`, { + expectedVersion: 1, + }); + + expect(res.status).toBe(200); + expect( + h.memory + .rows(core_audit_logs) + .filter(entry => entry.action === "report.claimed"), + ).toEqual([ + expect.objectContaining({ + actorId: MOD_A, + actorType: "staff", + area: "moderator", + changes: { + assigneeId: { from: null, to: MOD_A }, + status: { from: "new", to: "in_progress" }, + }, + scopeFamily: FAMILY, + scopeId: SECTION_A, + targetId: String(id), + targetType: "report", + }), + ]); + expect(h.eventsOf(id).map(event => event.type)).toEqual( + expect.arrayContaining(["assigned", "status_changed"]), + ); + }); + it("resolves with an outcome and reopens with the history kept", async () => { const h = await harness(); const id = await h.reportId(REPORTER, "1"); diff --git a/packages/vitnode/src/api/modules/moderation/routes/notes.routes.ts b/packages/vitnode/src/api/modules/moderation/routes/notes.routes.ts index f73a11e8c0..964b5a8dd9 100644 --- a/packages/vitnode/src/api/modules/moderation/routes/notes.routes.ts +++ b/packages/vitnode/src/api/modules/moderation/routes/notes.routes.ts @@ -5,6 +5,7 @@ import { buildRoute } from "@/api/builders"; import { requireModerator } from "@/api/lib/moderation/access"; import { core_moderation_report_events } from "@/database/moderation"; +import { recordReportAudit } from "../lib/audit"; import { canChangeNote, loadReportNote } from "../lib/notes"; import { appendReportEvent, @@ -80,6 +81,10 @@ export const editReportNoteRoute = buildRoute({ reportId: report.id, type: "note_edited", }); + await recordReportAudit(c, tx, report, { + action: "note_edited", + metadata: { noteAuthorId: note.actorId, noteId: note.id }, + }); }); await c.get("events").emit("moderation.report.note_updated", { @@ -119,6 +124,10 @@ export const deleteReportNoteRoute = buildRoute({ reportId: report.id, type: "note_deleted", }); + await recordReportAudit(c, tx, report, { + action: "note_deleted", + metadata: { noteAuthorId: note.actorId, noteId: note.id }, + }); }); await c.get("events").emit("moderation.report.note_deleted", { diff --git a/packages/vitnode/src/api/modules/moderation/routes/workflow.routes.ts b/packages/vitnode/src/api/modules/moderation/routes/workflow.routes.ts index 34f39426c8..a4fa8dcce5 100644 --- a/packages/vitnode/src/api/modules/moderation/routes/workflow.routes.ts +++ b/packages/vitnode/src/api/modules/moderation/routes/workflow.routes.ts @@ -6,8 +6,10 @@ import { resolveStaffPermissions } from "@/api/lib/check-staff-permission"; import { canOnReport, requireModerator } from "@/api/lib/moderation/access"; import { core_moderation_reports } from "@/database/moderation"; import { core_users } from "@/database/users"; +import { auditDiff } from "@/lib/audit/diff"; import { MODERATION_PERMISSIONS } from "@/lib/moderation/const"; +import { recordReportAudit, withReportAudit } from "../lib/audit"; import { listModeratorCandidates } from "../lib/candidates"; import { loadReportPeople } from "../lib/people"; import { @@ -88,6 +90,13 @@ export const claimReportRoute = buildRoute({ assigneeId: moderator.user.id, status: "in_progress", }); + await recordReportAudit(c, tx, report, { + action: "claimed", + changes: auditDiff({ + after: { assigneeId: moderator.user.id, status: "in_progress" }, + before: { assigneeId: report.assigneeId, status: report.status }, + }), + }); if (report.assigneeId !== moderator.user.id) { await appendReportEvent(tx, { actorId: moderator.user.id, @@ -180,6 +189,13 @@ export const assignReportRoute = buildRoute({ 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 recordReportAudit(c, tx, report, { + action: assigneeId === null ? "unassigned" : "assigned", + changes: auditDiff({ + after: { assigneeId, status }, + before: { assigneeId: report.assigneeId, status: report.status }, + }), + }); await appendReportEvent(tx, { actorId: moderator.user.id, data: { assigneeId, previousAssigneeId: report.assigneeId }, @@ -234,11 +250,16 @@ export const noteReportRoute = buildRoute({ manage: true, }); - await appendReportEvent(c.get("db"), { - actorId: moderator.user.id, - data: { body: note }, - reportId: report.id, - type: "note", + await c.get("db").transaction(async tx => { + await appendReportEvent(tx, { + actorId: moderator.user.id, + data: { body: note }, + reportId: report.id, + type: "note", + }); + // That a note was added - its text stays in the report's own timeline, + // behind the report's own permissions. + await recordReportAudit(c, tx, report, { action: "note_added" }); }); return c.json({ ok: true }, 201); @@ -284,6 +305,14 @@ export const resolveReportRoute = buildRoute({ resolvedById: moderator.user.id, status: "done", }); + await recordReportAudit(c, tx, report, { + action: "resolved", + changes: auditDiff({ + after: { resolution, status: "done" }, + before: { resolution: report.resolution, status: report.status }, + }), + metadata: { withExplanation: Boolean(explanation) }, + }); await appendReportEvent(tx, { actorId: moderator.user.id, data: explanation ? { explanation, resolution } : { resolution }, @@ -354,6 +383,13 @@ export const reopenReportRoute = buildRoute({ resolvedById: null, status, }); + await recordReportAudit(c, tx, report, { + action: "reopened", + changes: auditDiff({ + after: { resolution: null, status }, + before: { resolution: report.resolution, status: report.status }, + }), + }); await appendReportEvent(tx, { actorId: moderator.user.id, data: { @@ -416,11 +452,19 @@ export const reportActionRoute = buildRoute({ } const result = await c.get("db").transaction(async tx => { - const outcome = await definition.run(c, { - actorUserId: moderator.user.id, - targetId: report.targetId, - tx, - }); + // The content write the action performs is audited by its own owner (the + // Content Engine's `content.hidden`) - as a moderator's, in the report's + // scope. + const outcome = await withReportAudit( + c, + report, + async () => + await definition.run(c, { + actorUserId: moderator.user.id, + targetId: report.targetId, + tx, + }), + ); if (!outcome) return null; if (!outcome.changed) { return { outcome, state: stateOf(report) }; @@ -433,6 +477,10 @@ export const reportActionRoute = buildRoute({ reportId: report.id, type: "action", }); + await recordReportAudit(c, tx, report, { + action: "action_taken", + metadata: { action }, + }); return { outcome, state: stateOf(next) }; }); diff --git a/packages/vitnode/src/api/modules/queue/helpers/process-queue-tasks.ts b/packages/vitnode/src/api/modules/queue/helpers/process-queue-tasks.ts index fa54471602..1869429ef5 100644 --- a/packages/vitnode/src/api/modules/queue/helpers/process-queue-tasks.ts +++ b/packages/vitnode/src/api/modules/queue/helpers/process-queue-tasks.ts @@ -16,6 +16,7 @@ import { import type { QueueTaskConfig } from "@/api/lib/queue"; import type { EnvVitNode } from "@/api/middlewares/global.middleware"; +import { AUDIT_SYSTEM_ACTOR, withAuditContext } from "@/api/lib/audit/context"; import { QueueDeferError } from "@/api/lib/queue"; import { core_queue } from "@/database/queue"; import { resolveQueueTaskOutcome } from "@/lib/api/resolve-queue-task-outcome"; @@ -165,7 +166,18 @@ const runQueueTask = async ( await c.get("log").warn(error); } else { try { - await registered.handler(c, task.payload); + // A task runs as the system, whichever request happened to drain it. + await withAuditContext( + c, + { + actor: AUDIT_SYSTEM_ACTOR, + area: "system", + metadata: { queueTask: key, trigger: "queue_task" }, + }, + async () => { + await registered.handler(c, task.payload); + }, + ); } catch (err) { if (err instanceof QueueDeferError) { return { diff --git a/packages/vitnode/src/api/plugin.ts b/packages/vitnode/src/api/plugin.ts index bf77ee8e3d..3519b028c3 100644 --- a/packages/vitnode/src/api/plugin.ts +++ b/packages/vitnode/src/api/plugin.ts @@ -102,6 +102,12 @@ export const newBuildPluginApiCore = buildApiPlugin({ ], ai: ["can_view", { permission: "can_manage", dependsOn: ["can_view"] }], cron: ["can_view", { permission: "can_run", dependsOn: ["can_view"] }], + activity_logs: [ + "can_view", + { permission: "can_view_sensitive", dependsOn: ["can_view"] }, + { permission: "can_export", dependsOn: ["can_view"] }, + { permission: "can_manage_retention", dependsOn: ["can_view"] }, + ], report_reasons: [ "can_view", { permission: "can_create", dependsOn: ["can_view"] }, diff --git a/packages/vitnode/src/content/approvals/server/decide.ts b/packages/vitnode/src/content/approvals/server/decide.ts index 951ecb2a83..0834670631 100644 --- a/packages/vitnode/src/content/approvals/server/decide.ts +++ b/packages/vitnode/src/content/approvals/server/decide.ts @@ -12,6 +12,8 @@ import type { AnyContentTypeDefinition } from "../../types"; import type { ContentSubmissionDecisionCode } from "../const"; import type { ContentSubmissionRow } from "./submissions"; +import { withAuditContext } from "../../../api/lib/audit/context"; +import { writeAuditLog } from "../../../api/lib/audit/write"; import { core_content_submissions } from "../../../database/content-submissions"; import { CONTENT_PERMISSIONS } from "../../const"; import { contentContainerSourceOf } from "../../containers/association"; @@ -21,6 +23,7 @@ import { } from "../../containers/server/policy"; import { ContentInputError, ContentVersionConflict } from "../../errors"; import { parseContentId } from "../../ids"; +import { contentAuditLabel } from "../../server/audit"; import { contentDefinitionOf } from "../../server/model"; import { recordContentModerationDecision } from "../../server/notifications/intents"; import { publicationMethods } from "../../server/publication"; @@ -260,17 +263,26 @@ export const decideContentSubmission = async ( }); if (!allowed) throw new ContentSubmissionDecisionError("forbidden"); - const applied: Applied = - decision === "approve" - ? await applyApproval(c, tx, { - actor, - id: itemId, - model, - pluginId: submission.pluginId, - row, - submission, - }) - : { kind: "none" }; + // A moderator's decision, in the container's moderation scope - for the + // decision's own entry and for the write that approving it performs. + const scope = container + ? { family: container.definition.id, id: String(container.container.id) } + : null; + const applied: Applied = await withAuditContext( + c, + { area: "moderator", scope }, + async () => + decision === "approve" + ? await applyApproval(c, tx, { + actor, + id: itemId, + model, + pluginId: submission.pluginId, + row, + submission, + }) + : { kind: "none" }, + ); const [decided] = await tx .update(core_content_submissions) @@ -293,6 +305,29 @@ export const decideContentSubmission = async ( // decision commits. await recordContentModerationDecision(c, tx, decided); + await writeAuditLog( + c, + { + action: `submission.${decision === "approve" ? "approved" : "rejected"}`, + actor, + area: "moderator", + contentTypeId: definition.id, + metadata: { + contentId: submission.itemId, + kind: submission.kind, + ...(decision === "reject" ? { withReason: Boolean(reason) } : {}), + }, + pluginId: submission.pluginId, + scope, + target: { + id: submission.id, + label: contentAuditLabel(definition, row), + type: "submission", + }, + }, + tx, + ); + return { applied, decided, kind: "decided" as const, model }; }); diff --git a/packages/vitnode/src/content/define-audit.test.ts b/packages/vitnode/src/content/define-audit.test.ts new file mode 100644 index 0000000000..d5436cee07 --- /dev/null +++ b/packages/vitnode/src/content/define-audit.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; + +import type { ContentAuditInput } from "./types"; + +import { defineContentType } from "./define"; +import { ContentEngineError } from "./errors"; +import { field } from "./fields"; + +const fields = { + title: field.text({ required: true }), + views: field.number({ integer: true, defaultValue: 0 }), +}; + +const define = (audit?: ContentAuditInput | false) => + defineContentType({ + id: "test.audited", + tableName: "test_audited", + fields, + ...(audit === undefined ? {} : { audit }), + }); + +describe("audit", () => { + it.each([ + [ + "is on and tracks changes by default", + undefined, + { enabled: true, excludeFields: [], trackChanges: true }, + ], + [ + "is off with false", + false, + { enabled: false, excludeFields: [], trackChanges: false }, + ], + [ + "keeps the excluded fields and the trackChanges choice", + { excludeFields: ["views"], trackChanges: false }, + { enabled: true, excludeFields: ["views"], trackChanges: false }, + ], + ] as const)("%s", (_label, audit, resolved) => { + expect(define(audit).audit).toEqual(resolved); + }); + + it("rejects an excluded field the content type does not have", () => { + expect(() => + define({ + excludeFields: ["missing" as keyof typeof fields], + }), + ).toThrow(ContentEngineError); + }); +}); diff --git a/packages/vitnode/src/content/define.ts b/packages/vitnode/src/content/define.ts index 62fc25743c..3364b4fdb0 100644 --- a/packages/vitnode/src/content/define.ts +++ b/packages/vitnode/src/content/define.ts @@ -2,6 +2,7 @@ import type { ContentIdStrategy } from "./ids"; import type { AnyContentTypeDefinition, ContentAdminConfig, + ContentAuditInput, ContentDeliveryConfig, ContentDeliveryDescriptionField, ContentDeliveryEnabled, @@ -37,6 +38,7 @@ import type { ContentSearchTitleField, ContentThreadInput, ContentTypeDefinition, + ResolvedContentAuditConfig, ResolvedContentDeliveryConfig, ResolvedContentDuplicationConfig, ResolvedContentEditorialConfig, @@ -159,6 +161,38 @@ const assertIndexable = ( } }; +const resolveContentAudit = ( + id: string, + audit: ContentAuditInput | false | undefined, + fieldNames: readonly string[], +): ResolvedContentAuditConfig => { + if (audit === false) { + return { enabled: false, excludeFields: [], trackChanges: false }; + } + if (audit !== undefined && (typeof audit !== "object" || audit === null)) { + throw new ContentEngineError( + "audit is `false` or an options object. Omit it to keep the default.", + { contentTypeId: id }, + ); + } + + const excludeFields = [...(audit?.excludeFields ?? [])]; + for (const field of excludeFields) { + if (!fieldNames.includes(field)) { + throw new ContentEngineError( + `audit.excludeFields references unknown field "${field}".`, + { contentTypeId: id }, + ); + } + } + + return { + enabled: true, + excludeFields, + trackChanges: audit?.trackChanges ?? true, + }; +}; + /** * Declares a Content Engine content type: its fields, its storage and the * capabilities (publication, localization, public API, editorial workflow, @@ -268,6 +302,7 @@ export const defineContentType = < TPublicListField extends TPublicField = never, >({ admin = {}, + audit, delivery, duplication, editorial, @@ -297,6 +332,14 @@ export const defineContentType = < ContentEditorialEnabled >; + /** + * Automatic audit logging: every create, edit, publication, visibility, + * schedule and translation write is recorded in the central audit log with + * its actor and changed fields. On by default; `false` turns it off, and + * `excludeFields` keeps noisy fields out of the recorded changes. + */ + audit?: ContentAuditInput> | false; + /** * Public delivery: canonical URLs (`/{publicApi.path}/:slug` by default), SEO * metadata, localized alternates, sitemap entries and, with `redirects`, slug @@ -721,6 +764,7 @@ export const defineContentType = < > = { admin: resolvedAdmin, advanced: resolvedAdvanced, + audit: resolveContentAudit(id, audit, fieldNames), delivery: resolvedDelivery as ResolvedContentDeliveryConfig< ContentDeliveryEnabled >, diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts index 7e3e251646..e8f62ade8b 100644 --- a/packages/vitnode/src/content/index.ts +++ b/packages/vitnode/src/content/index.ts @@ -543,6 +543,7 @@ export type { ContentAdminFormMode, ContentAdminFormSection, ContentAdminListConfig, + ContentAuditInput, ContentBlocksField, ContentBooleanField, ContentCreateInput, @@ -658,6 +659,7 @@ export type { PublicFilterableContentFieldName, PublicWritableContentTypeDefinition, ResolvedContentAdminConfig, + ResolvedContentAuditConfig, ResolvedContentDeliveryConfig, ResolvedContentDeliverySeoConfig, ResolvedContentDuplicationConfig, diff --git a/packages/vitnode/src/content/server/audit.test.ts b/packages/vitnode/src/content/server/audit.test.ts new file mode 100644 index 0000000000..3381ade5be --- /dev/null +++ b/packages/vitnode/src/content/server/audit.test.ts @@ -0,0 +1,148 @@ +import type { Context } from "hono"; + +import { describe, expect, it } from "vitest"; + +import type { AnyContentTypeDefinition } from "../types"; +import type { ContentAuditWrite } from "./audit"; +import type { ContentDatabase } from "./service"; + +import { writeAuditLog } from "../../api/lib/audit/write"; +import { core_audit_logs } from "../../database/audit"; +import { core_users } from "../../database/users"; +import { createMemoryDb } from "../../tests/memory-db"; +import { defineContentType } from "../define"; +import { field } from "../fields"; +import { + collectContentTranslationAudit, + recordContentAudit, + withContentDuplicateAudit, +} from "./audit"; + +const definition = defineContentType({ + id: "test.note", + tableName: "test_notes", + fields: { + title: field.text({ required: true }), + views: field.number({ integer: true, defaultValue: 0 }), + }, + audit: { excludeFields: ["views"] }, +}) as AnyContentTypeDefinition; + +const EDITOR = { id: 4, name: "Editor" }; + +const harness = () => { + const memory = createMemoryDb([[core_users, [EDITOR]]]); + const c = { + get: (key: string) => + key === "db" ? memory.db : key === "admin" ? { user: EDITOR } : undefined, + } as unknown as Context; + const tx = memory.db as unknown as ContentDatabase; + + const record = async (overrides: Partial) => + await recordContentAudit(c, tx, { + action: "created", + actor: { type: "staff", userId: EDITOR.id }, + definition, + itemId: 9, + pluginId: "@vitnode/notes", + ...overrides, + }); + + return { + c, + entries: () => memory.rows(core_audit_logs), + record, + }; +}; + +describe("recordContentAudit", () => { + it("names the changed fields without the excluded ones", async () => { + const h = harness(); + + await h.record({ action: "updated", changedFields: ["title", "views"] }); + + expect(h.entries()[0]).toMatchObject({ + action: "content.updated", + contentTypeId: "test.note", + metadata: { changedFields: ["title"] }, + pluginId: "@vitnode/notes", + targetType: "test.note", + }); + }); + + it("drops values and field names when trackChanges is off", async () => { + const h = harness(); + + await h.record({ + action: "updated", + changedFields: ["title"], + changes: { title: { from: "a", to: "b" } }, + definition: { + ...definition, + audit: { ...definition.audit, trackChanges: false }, + }, + }); + + expect(h.entries()[0]).toMatchObject({ changes: null, metadata: {} }); + }); + + it("writes a system actor's entry to the system area", async () => { + const h = harness(); + + await h.record({ + action: "published", + actor: { type: "system", userId: null }, + }); + + expect(h.entries()[0]).toMatchObject({ + actorId: null, + actorType: "system", + area: "system", + ipAddress: null, + }); + }); +}); + +describe("withContentDuplicateAudit", () => { + it("leaves one content.duplicated entry naming the source, and no translation entries", async () => { + const h = harness(); + + await withContentDuplicateAudit(h.c, definition, 3, async () => { + await h.record({ action: "created" }); + await h.record({ action: "translation_created", locale: "pl" }); + await writeAuditLog(h.c, { + action: "role.updated", + target: { id: 1, type: "role" }, + }); + }); + + expect(h.entries()).toEqual([ + expect.objectContaining({ + action: "content.duplicated", + metadata: { sourceId: 3 }, + targetId: "9", + }), + expect.objectContaining({ action: "role.updated" }), + ]); + }); +}); + +describe("collectContentTranslationAudit", () => { + it("holds back the record's translation entries and writes the rest", async () => { + const h = harness(); + + const { entries } = await collectContentTranslationAudit( + h.c, + definition, + async () => { + await h.record({ action: "translation_published", locale: "pl" }); + await h.record({ action: "published" }); + }, + ); + + expect(entries.map(entry => entry.metadata.locale)).toEqual(["pl"]); + expect(h.entries().map(entry => entry.action)).toEqual([ + "content.published", + ]); + }); +}); diff --git a/packages/vitnode/src/content/server/audit.ts b/packages/vitnode/src/content/server/audit.ts new file mode 100644 index 0000000000..68d5a18cb0 --- /dev/null +++ b/packages/vitnode/src/content/server/audit.ts @@ -0,0 +1,431 @@ +import type { Context } from "hono"; + +import type { AuditPendingEntry } from "../../api/lib/audit/context"; +import type { AuditChanges } from "../../lib/audit/const"; +import type { ContentEventAction } from "../events"; +import type { ContentId } from "../ids"; +import type { ContentActor } from "../revisions"; +import type { AnyContentTypeDefinition } from "../types"; +import type { ContentDatabase } from "./service"; + +import { withAuditContext } from "../../api/lib/audit/context"; +import { writeAuditLog } from "../../api/lib/audit/write"; +import { AUDIT_CORE_PLUGIN_ID } from "../../lib/audit/const"; +import { auditDiff } from "../../lib/audit/diff"; +import { partitionContentFields } from "../localization"; + +/** + * The Content Engine operations that leave an audit entry, named after the + * content event they correspond to. Delivery events are not audited: a slug + * change is already part of the `updated` entry that caused it. + */ +export type ContentAuditAction = Exclude< + ContentEventAction, + "delivery_redirect_created" | "delivery_slug_changed" +>; + +/** The audit action stored for a content operation: `content.updated`. */ +export const contentAuditAction = (action: ContentAuditAction): string => + `content.${action}`; + +/** Revision operations of the shared history, mapped to their audit action. */ +export const CONTENT_REVISION_AUDIT_ACTIONS = { + create: "created", + delete: "deleted", + hide: "hidden", + publish: "published", + restore: "restored", + unhide: "unhidden", + unpublish: "unpublished", + update: "updated", +} as const satisfies Record; + +/** Revision operations of one translation's history, mapped to their audit action. */ +export const CONTENT_TRANSLATION_REVISION_AUDIT_ACTIONS = { + create: "translation_created", + delete: "translation_deleted", + publish: "translation_published", + restore: "translation_restored", + unpublish: "translation_unpublished", + update: "translation_updated", +} as const satisfies Record; + +/** One Content Engine operation to record, inside the write's transaction. */ +export interface ContentAuditWrite { + action: ContentAuditAction; + actor: ContentActor; + /** Canonical field paths the write moved (`seo.title`, `categories`). */ + changedFields?: readonly string[]; + /** + * Bounded before/after values, for a content type without revisions. An + * editorial content type leaves this out: its revision holds the values. + */ + changes?: AuditChanges | null; + definition: AnyContentTypeDefinition; + itemId: ContentId; + /** The record's title at the time of the write, when the row carries one. */ + label?: null | string; + /** Only for a translation operation. */ + locale?: string; + metadata?: Record; + pluginId: string; + revisionId?: null | number; +} + +/** + * The plugin a content type belongs to, from the content registry - never + * from the request. Falls back to the route's plugin for a content type bound + * outside the registry (tests, custom wiring). + */ +export const contentPluginIdOf = ( + c: Context, + definition: AnyContentTypeDefinition, +): string => + c + .get("core") + ?.contentModels?.find(entry => entry.model.definition.id === definition.id) + ?.pluginId ?? + c.get("plugin")?.id ?? + AUDIT_CORE_PLUGIN_ID; + +/** Whether `path` (or the group it belongs to) is one of `excluded`. */ +const isExcludedPath = (path: string, excluded: readonly string[]): boolean => + excluded.includes(path) || excluded.includes(path.split(".")[0]); + +/** + * The title an entry is labelled with: `admin.titleField` read off the row, + * when it is a non-empty string on this row. A localized title lives on the + * translation row, so a base-row write of a localized content type has none. + */ +export const contentAuditLabel = ( + definition: AnyContentTypeDefinition, + row: null | Record | undefined, +): null | string => { + const field = definition.admin.titleField; + if (!row || field === null) return null; + const value = + row[field] ?? (row.values as Record | undefined)?.[field]; + + return typeof value === "string" && value.trim() !== "" ? value : null; +}; + +/** + * {@link contentAuditLabel}, falling back to the default translation's title + * for a localized content type - read with `readDefaultTranslation`, inside the + * write's transaction (and before a delete, while it still exists). + */ +export const resolveContentAuditLabel = async ( + definition: AnyContentTypeDefinition, + row: null | Record | undefined, + readDefaultTranslation?: () => Promise, +): Promise => { + const own = contentAuditLabel(definition, row); + if ( + own !== null || + !readDefaultTranslation || + !definition.audit.enabled || + !definition.localization.enabled + ) { + return own; + } + + return contentAuditLabel( + definition, + (await readDefaultTranslation()) as null | Record, + ); +}; + +const valueAt = (row: Record, path: string): unknown => { + let value: unknown = row; + for (const segment of path.split(".")) { + if (value === null || typeof value !== "object") return null; + value = (value as Record)[segment]; + } + + return value ?? null; +}; + +/** + * Bounded before/after values of the scalar paths a plain (revisionless) + * write moved. Collections are named in `changedFields` only: a list of ids + * says little that the record itself does not. + */ +export const contentAuditChanges = ( + definition: AnyContentTypeDefinition, + { + after, + before, + changedFields, + scope = "shared", + }: { + after: Record; + before: Record; + changedFields: readonly string[]; + /** Which half of the fields the rows hold: the base row's or a translation's. */ + scope?: "localized" | "shared"; + }, +): AuditChanges | null => { + const audit = definition.audit; + if (!audit.trackChanges) return null; + + const { localizedFields, sharedFields } = partitionContentFields( + definition.fields, + ); + const scalars = scope === "localized" ? localizedFields : sharedFields; + const paths = changedFields.filter( + path => + !isExcludedPath(path, audit.excludeFields) && + scalars[path.split(".")[0]] !== undefined, + ); + if (paths.length === 0) return null; + + return auditDiff({ + after: Object.fromEntries(paths.map(path => [path, valueAt(after, path)])), + before: Object.fromEntries( + paths.map(path => [path, valueAt(before, path)]), + ), + }); +}; + +/** + * Writes the audit entry of one Content Engine operation **inside** its + * transaction, so the entry exists exactly when the write committed. Called by + * the editorial and translation revision captures and by the plain services - + * never by a post-commit event listener. A no-op when the content type has + * `audit: false`. + */ +export const recordContentAudit = async ( + c: Context, + tx: ContentDatabase, + { + action, + actor, + changedFields = [], + changes = null, + definition, + itemId, + label = null, + locale, + metadata, + pluginId, + revisionId = null, + }: ContentAuditWrite, +): Promise => { + const audit = definition.audit; + if (!audit.enabled) return; + + const fields = audit.trackChanges + ? changedFields.filter(path => !isExcludedPath(path, audit.excludeFields)) + : []; + const itemKey = String(itemId); + + await writeAuditLog( + c, + { + action: contentAuditAction(action), + actor, + ...(actor.type === "system" ? { area: "system" as const } : {}), + changes: audit.trackChanges ? changes : null, + contentTypeId: definition.id, + metadata: { + ...(fields.length > 0 ? { changedFields: fields } : {}), + ...(locale === undefined ? {} : { locale }), + ...metadata, + }, + pluginId, + revisionId, + target: { id: itemKey, label, type: definition.id }, + }, + tx, + ); +}; + +const isOwnEntry = ( + entry: AuditPendingEntry, + definition: AnyContentTypeDefinition, +): boolean => entry.contentTypeId === definition.id; + +/** + * Runs a duplicate so that it leaves one `content.duplicated` entry: the copy's + * own `created` entry is rewritten (it names the new record and the source), + * and the copied translations, created as part of the same operation, leave + * none of their own. Retried slug attempts roll their entry back with their + * savepoint, so a retry never doubles it. + */ +export const withContentDuplicateAudit = async ( + c: Context, + definition: AnyContentTypeDefinition, + sourceId: ContentId, + body: () => Promise, +): Promise => + await withAuditContext( + c, + { + intercept: entry => { + if (!isOwnEntry(entry, definition)) return entry; + if (entry.action === contentAuditAction("created")) { + return { + ...entry, + action: contentAuditAction("duplicated"), + metadata: { ...entry.metadata, sourceId }, + }; + } + if (entry.action === contentAuditAction("translation_created")) { + return null; + } + + return entry; + }, + }, + body, + ); + +/** + * Runs the translation cascade of a record publish or unpublish and holds back + * the per-language entries it would write, so the caller can fold them into + * the record's own entry (`metadata.locales`) - or write them after all when + * the record itself did not move. + */ +export const collectContentTranslationAudit = async ( + c: Context, + definition: AnyContentTypeDefinition, + body: () => Promise, +): Promise<{ entries: AuditPendingEntry[]; result: TResult }> => { + const entries: AuditPendingEntry[] = []; + const result = await withAuditContext( + c, + { + intercept: entry => { + if ( + !isOwnEntry(entry, definition) || + !entry.action.startsWith("content.translation_") + ) { + return entry; + } + entries.push(entry); + + return null; + }, + }, + body, + ); + + return { entries, result }; +}; + +/** Writes entries {@link collectContentTranslationAudit} held back. */ +export const writeContentAuditEntries = async ( + c: Context, + tx: ContentDatabase, + entries: readonly AuditPendingEntry[], +): Promise => { + for (const entry of entries) await writeAuditLog(c, entry, tx); +}; + +/** The locales held-back translation entries name, for the folded entry. */ +export const contentAuditLocales = ( + entries: readonly AuditPendingEntry[], +): string[] => + entries + .map(entry => entry.metadata.locale) + .filter((locale): locale is string => typeof locale === "string"); + +/** + * Runs one localized AdminCP save - the base row and any number of its + * translations, in `tx` - and writes its audit entries at the end of it: + * + * - a **create** leaves one `content.created` entry, labelled with the default + * translation's title and listing the languages it was created in; + * - an **update** leaves an entry per part that moved (the shared fields, each + * language), each labelled with the record's title even when only the + * translation carries it. + */ +export const withContentSaveAudit = async ( + c: Context, + definition: AnyContentTypeDefinition, + tx: ContentDatabase, + kind: "create" | "update", + body: () => Promise, +): Promise => { + const held: AuditPendingEntry[] = []; + const result = await withAuditContext( + c, + { + intercept: entry => { + if (!isOwnEntry(entry, definition)) return entry; + held.push(entry); + + return null; + }, + }, + body, + ); + + const defaultLocale = definition.localization.enabled + ? definition.localization.defaultLocale + : null; + const translations = held.filter( + entry => entry.metadata.locale !== undefined, + ); + // The title as the save left it: the default translation's, when this save + // wrote one - a shared-field entry read the title before the translations + // moved. + const label = + translations.find(entry => entry.metadata.locale === defaultLocale)?.target + .label ?? + held.find(entry => entry.target.label !== null)?.target.label ?? + null; + const labelled = (entry: AuditPendingEntry): AuditPendingEntry => ({ + ...entry, + target: { + ...entry.target, + label: + entry.metadata.locale === undefined + ? (label ?? entry.target.label) + : (entry.target.label ?? label), + }, + }); + + const created = held.find( + entry => entry.action === contentAuditAction("created"), + ); + if (kind === "create" && created) { + const folded = translations.filter( + entry => entry.action === contentAuditAction("translation_created"), + ); + const changedFields = [ + ...new Set( + [created, ...folded].flatMap(entry => + Array.isArray(entry.metadata.changedFields) + ? (entry.metadata.changedFields as unknown[]) + : [], + ), + ), + ]; + await writeAuditLog( + c, + labelled({ + ...created, + metadata: { + ...created.metadata, + ...(changedFields.length > 0 ? { changedFields } : {}), + locales: contentAuditLocales(folded), + }, + }), + tx, + ); + await writeContentAuditEntries( + c, + tx, + held + .filter(entry => entry !== created && !folded.includes(entry)) + .map(labelled), + ); + + return result; + } + + await writeContentAuditEntries(c, tx, held.map(labelled)); + + return result; +}; diff --git a/packages/vitnode/src/content/server/editorial-service.test.ts b/packages/vitnode/src/content/server/editorial-service.test.ts index 4e72792ed1..f80ca9ea44 100644 --- a/packages/vitnode/src/content/server/editorial-service.test.ts +++ b/packages/vitnode/src/content/server/editorial-service.test.ts @@ -2,6 +2,7 @@ import type { Context } from "hono"; import { describe, expect, it } from "vitest"; +import { createAuditRecorder } from "@/tests/audit-recorder"; import { testCategoryContentType, testEditorialNoteContentType, @@ -75,9 +76,10 @@ const createDbMock = ( return chain(queue.shift() ?? []); }; + const audit = createAuditRecorder(); const db = { delete: start("delete"), - insert: start("insert"), + insert: (table: unknown) => audit.insert(table) ?? start("insert")(table), select: start("select"), transaction: async ( body: (tx: unknown) => Promise, diff --git a/packages/vitnode/src/content/server/editorial-service.ts b/packages/vitnode/src/content/server/editorial-service.ts index 8b9c044460..d6b5b5f0ef 100644 --- a/packages/vitnode/src/content/server/editorial-service.ts +++ b/packages/vitnode/src/content/server/editorial-service.ts @@ -65,6 +65,15 @@ import { import { contentRelationStrategy, requireContentId } from "../ids"; import { partitionContentFields } from "../localization"; import { contentColumnsToValues, contentStorageColumns } from "../paths"; +import { + collectContentTranslationAudit, + CONTENT_REVISION_AUDIT_ACTIONS, + contentAuditLocales, + recordContentAudit, + resolveContentAuditLabel, + withContentDuplicateAudit, + writeContentAuditEntries, +} from "./audit"; import { buildContentRelationOperations, buildContentRepeatableOperations, @@ -524,10 +533,37 @@ export const createContentEditorialService = < return await c.get("db").transaction(async tx => await body(tx)); }; + /** + * The record's title for its audit entry. A localized title lives on the + * default translation, read in this transaction - before a delete, since the + * translations go with the row. + */ + const auditLabel = async ( + tx: ContentDatabase, + itemId: ContentId, + row: null | Record, + ): Promise => + await resolveContentAuditLabel( + definition, + row, + translations && definition.localization.enabled + ? async () => + await translations + .model() + .findByLocale( + itemId as ContentIdOf, + definition.localization.defaultLocale, + { tx }, + ) + : undefined, + ); + const capture = async ( tx: ContentDatabase, { actor, + auditLabel: knownLabel, + auditMetadata, changedFields, operation, restoredFromRevisionId, @@ -535,6 +571,9 @@ export const createContentEditorialService = < version, }: { actor: ContentActor; + /** Read before a delete, while the translations still exist. */ + auditLabel?: null | string; + auditMetadata?: Record; changedFields: readonly string[]; operation: ContentRevisionOperation; restoredFromRevisionId?: number; @@ -562,7 +601,7 @@ export const createContentEditorialService = < version, }); - return await revisions.capture(tx, { + const revisionId = await revisions.capture(tx, { actor, changedFields, // Pinned in the same statement batch as the revision: a snapshot naming a @@ -578,6 +617,29 @@ export const createContentEditorialService = < snapshot, version, }); + + // One revision, one audit entry, one transaction: the entry exists exactly + // when the write does. It points at the revision rather than copying the + // values, which the revision already holds. + await recordContentAudit(c, tx, { + action: CONTENT_REVISION_AUDIT_ACTIONS[operation], + actor, + changedFields, + definition, + itemId, + label: knownLabel ?? (await auditLabel(tx, itemId, row)), + metadata: { + version, + ...(restoredFromRevisionId === undefined + ? {} + : { restoredFromRevisionId }), + ...auditMetadata, + }, + pluginId, + revisionId, + }); + + return revisionId; }; /** @@ -710,6 +772,15 @@ export const createContentEditorialService = < .where(and(...conditions)) .returning(ownSelection()); + // Each language the record moves with it is part of this one operation, + // so its audit entry is held back and folded into the record's own. + const cascadeAudited = async () => + await collectContentTranslationAudit( + c, + definition, + async () => await cascade(tx, id, operation, options.actor), + ); + if (!row) { const current = await readOne(id, tx); if (!current) return null; @@ -735,7 +806,10 @@ export const createContentEditorialService = < // reconcile them: the transition is guarded on the base row, so it would // short-circuit here for ever. Publishing means "make this published", // and each language's own guard keeps it a no-op once it is. - const moved = await cascade(tx, id, operation, options.actor); + const { entries, result: moved } = await cascadeAudited(); + // The record did not move, so there is no entry to fold into: each + // language that did move is its own operation after all. + await writeContentAuditEntries(c, tx, entries); return { changed: false, @@ -765,11 +839,13 @@ export const createContentEditorialService = < // After the base row moves and before this record's own revision, so each // language's delivery reservation is taken against a record that is already // publicly visible: a localized address is only public when both halves are. - const moved = await cascade(tx, id, operation, options.actor); + const { entries, result: moved } = await cascadeAudited(); + const locales = contentAuditLocales(entries); const version = versionOf(row); const revisionId = await capture(tx, { actor: options.actor, + ...(locales.length > 0 ? { auditMetadata: { locales } } : {}), changedFields: [], operation, row, @@ -1076,6 +1152,10 @@ export const createContentEditorialService = < // child rows with the record, so this is the last moment the history can // record what the record was related to. const collections = (await store?.load(id, tx)) ?? {}; + // Likewise the title: a localized one goes with the translation rows. + const label = definition.localization.enabled + ? await auditLabel(tx, id, null) + : null; // Same guard as `guardedWrite`, in a `DELETE` - the version has to be // part of the statement that removes the row, not checked before it. @@ -1115,6 +1195,7 @@ export const createContentEditorialService = < const version = versionOf(row) + 1; const revisionId = await capture(tx, { actor: options.actor, + auditLabel: label, changedFields: [], operation: "delete", row: { ...row, __collections: collections }, @@ -1446,49 +1527,58 @@ export const createContentEditorialService = < const duplication: ContentEditorialDuplicationMethods = { duplicate: async (sourceId, options) => { - const result = await runContentDuplicate( - { - advanced: store, - c, - columns, - definition, - pluginId, - table, - translation: - translations as ContentDuplicateDependencies["translation"], - }, + const result = await withContentDuplicateAudit( + c, + definition, sourceId, - options, - { - base: async (values, tx) => - await editorial.create(values as ContentCreateInput, { - actor: options.actor, - tx, - }), - idOf: outcome => - requireContentId( - definition.idStrategy, - (outcome.row as { id: unknown }).id, - contentTypeId, - ), - translation: async (itemId, locale, values, tx) => { - if (!translations) { - throw new ContentEngineError( - "Duplicating a translation needs the translation editorial service.", - { contentTypeId }, - ); - } - - return await translations - .editorial() - .create( - itemId as ContentIdOf, - locale, - values as ContentLocalizedValues, - { actor: options.actor, tx }, - ); - }, - }, + async () => + await runContentDuplicate( + { + advanced: store, + c, + columns, + definition, + pluginId, + table, + translation: + translations as ContentDuplicateDependencies["translation"], + }, + sourceId, + options, + { + base: async (values, tx) => + await editorial.create( + values as ContentCreateInput, + { + actor: options.actor, + tx, + }, + ), + idOf: outcome => + requireContentId( + definition.idStrategy, + (outcome.row as { id: unknown }).id, + contentTypeId, + ), + translation: async (itemId, locale, values, tx) => { + if (!translations) { + throw new ContentEngineError( + "Duplicating a translation needs the translation editorial service.", + { contentTypeId }, + ); + } + + return await translations + .editorial() + .create( + itemId as ContentIdOf, + locale, + values as ContentLocalizedValues, + { actor: options.actor, tx }, + ); + }, + }, + ), ); return result diff --git a/packages/vitnode/src/content/server/localized-admin-routes.ts b/packages/vitnode/src/content/server/localized-admin-routes.ts index 30944367b9..764817e6ee 100644 --- a/packages/vitnode/src/content/server/localized-admin-routes.ts +++ b/packages/vitnode/src/content/server/localized-admin-routes.ts @@ -28,6 +28,7 @@ import { CONTENT_LOCALE_MAX_LENGTH, CONTENT_PERMISSIONS } from "../const"; import { zodContentFileReferenceRejection } from "../files"; import { requireContentId } from "../ids"; import { resolveContentActor } from "./actor"; +import { withContentSaveAudit } from "./audit"; import { contentEditorialEffects } from "./editorial-effects"; import { emitContentEvent } from "./emit"; import { contentFileFields } from "./files"; @@ -328,35 +329,44 @@ export const buildContentLocalizedAdminRoutes = < const result = await withHttpErrors( "create", async () => - await c.get("db").transaction(async tx => { - const outcome = editorial - ? await editorialService(c).create(body.values, { - actor: resolveContentActor(c), - tx, - }) - : null; - const row = - outcome?.row ?? - (await model.service(c).create(body.values, { tx })); - - const written: ContentTranslationEditorialOutcome[] = - []; - for (const entry of ordered) { - const translated = await writeTranslation( + await c.get("db").transaction( + async tx => + await withContentSaveAudit( c, - requireContentId( - definition.idStrategy, - (row as { id: unknown }).id, - definition.id, - ) as ContentIdOf, - entry, + definition, tx, - ); - if (translated) written.push(translated); - } - - return { outcome, row, written }; - }), + "create", + async () => { + const outcome = editorial + ? await editorialService(c).create(body.values, { + actor: resolveContentActor(c), + tx, + }) + : null; + const row = + outcome?.row ?? + (await model.service(c).create(body.values, { tx })); + + const written: ContentTranslationEditorialOutcome[] = + []; + for (const entry of ordered) { + const translated = await writeTranslation( + c, + requireContentId( + definition.idStrategy, + (row as { id: unknown }).id, + definition.id, + ) as ContentIdOf, + entry, + tx, + ); + if (translated) written.push(translated); + } + + return { outcome, row, written }; + }, + ), + ), { contentTypeId: definition.id, structured: editorial }, ); @@ -413,51 +423,63 @@ export const buildContentLocalizedAdminRoutes = < const result = await withHttpErrors( "update", async () => - await c.get("db").transaction(async tx => { - // Shared first, so a base version conflict costs nothing: the - // translations are never written and the transaction ends here. - let outcome: ContentEditorialOutcome | null = null; - let changedFields: readonly string[] = []; - let row: ContentSelect | null = null; - - if (shared !== undefined) { - if (editorial) { - outcome = await editorialService(c).update(id, shared, { - actor: resolveContentActor(c), - expectedVersion: body.expectedVersion ?? 0, - tx, - }); - if (!outcome) return null; - changedFields = outcome.changedFields; - row = outcome.row; - } else { - const plain = await model.service(c).update(id, shared, { tx }); - if (!plain) return null; - changedFields = plain.changedFields; - row = plain.row; - } - } - - const written: ContentTranslationEditorialOutcome[] = - []; - for (const entry of entries) { - const translated = await writeTranslation(c, id, entry, tx); - if (translated) written.push(translated); - } - - const current = - row ?? (await model.service(c).findById(id, { tx })); - - return current === null - ? null - : { - changedFields, - outcome, - row: current, - touched: row !== null, - written, - }; - }), + await c.get("db").transaction( + async tx => + await withContentSaveAudit( + c, + definition, + tx, + "update", + async () => { + // Shared first, so a base version conflict costs nothing: the + // translations are never written and the transaction ends here. + let outcome: ContentEditorialOutcome | null = + null; + let changedFields: readonly string[] = []; + let row: ContentSelect | null = null; + + if (shared !== undefined) { + if (editorial) { + outcome = await editorialService(c).update(id, shared, { + actor: resolveContentActor(c), + expectedVersion: body.expectedVersion ?? 0, + tx, + }); + if (!outcome) return null; + changedFields = outcome.changedFields; + row = outcome.row; + } else { + const plain = await model + .service(c) + .update(id, shared, { tx }); + if (!plain) return null; + changedFields = plain.changedFields; + row = plain.row; + } + } + + const written: ContentTranslationEditorialOutcome[] = + []; + for (const entry of entries) { + const translated = await writeTranslation(c, id, entry, tx); + if (translated) written.push(translated); + } + + const current = + row ?? (await model.service(c).findById(id, { tx })); + + return current === null + ? null + : { + changedFields, + outcome, + row: current, + touched: row !== null, + written, + }; + }, + ), + ), { contentTypeId: definition.id, itemId: id, structured: editorial }, ); diff --git a/packages/vitnode/src/content/server/localized-service.test.ts b/packages/vitnode/src/content/server/localized-service.test.ts index ac92cc09f8..17cb0327d6 100644 --- a/packages/vitnode/src/content/server/localized-service.test.ts +++ b/packages/vitnode/src/content/server/localized-service.test.ts @@ -2,6 +2,7 @@ import type { Context } from "hono"; import { describe, expect, it } from "vitest"; +import { createAuditRecorder } from "@/tests/audit-recorder"; import { testLocalizedArticleContentType, testPostContentType, @@ -99,9 +100,10 @@ const createDbMock = ( ); }; + const audit = createAuditRecorder(); const db = { delete: start("delete"), - insert: start("insert"), + insert: (table: unknown) => audit.insert(table) ?? start("insert")(table), select: start("select"), transaction: async ( body: (tx: unknown) => Promise, diff --git a/packages/vitnode/src/content/server/schedules-model.ts b/packages/vitnode/src/content/server/schedules-model.ts index 16350f6629..196a08c8f3 100644 --- a/packages/vitnode/src/content/server/schedules-model.ts +++ b/packages/vitnode/src/content/server/schedules-model.ts @@ -17,6 +17,8 @@ import { CONTENT_QUEUE_TASK_SCHEDULE, CONTENT_SCHEDULE_CODES } from "../const"; import { ContentScheduleError } from "../errors"; import { contentIdKey } from "../ids"; import { contentScheduleTimingError } from "../schedules"; +import { resolveContentActor } from "./actor"; +import { recordContentAudit } from "./audit"; /** A schedule row claimed for execution, with everything the handler needs. */ export interface ClaimedContentSchedule { @@ -170,24 +172,41 @@ export const createContentSchedulesModel = ({ .where(and(scope(itemId), eq(core_content_schedules.status, "pending"))); return { - cancel: async (itemId, scheduleId) => { - const [row] = await c - .get("db") - .update(core_content_schedules) - .set({ status: "cancelled" }) - .where( - and( - scope(itemId), - eq(core_content_schedules.id, scheduleId), - // Only a pending one can be cancelled. Re-cancelling a completed - // schedule would rewrite history to say it never ran. - eq(core_content_schedules.status, "pending"), - ), - ) - .returning({ action: core_content_schedules.action }); + cancel: async (itemId, scheduleId) => + await c.get("db").transaction(async tx => { + const [row] = await tx + .update(core_content_schedules) + .set({ status: "cancelled" }) + .where( + and( + scope(itemId), + eq(core_content_schedules.id, scheduleId), + // Only a pending one can be cancelled. Re-cancelling a completed + // schedule would rewrite history to say it never ran. + eq(core_content_schedules.status, "pending"), + ), + ) + .returning({ + action: core_content_schedules.action, + scheduledFor: core_content_schedules.scheduledFor, + }); + if (!row) return null; + + await recordContentAudit(c, tx, { + action: "schedule_cancelled", + actor: resolveContentActor(c), + definition, + itemId, + metadata: { + scheduleAction: row.action, + scheduledFor: row.scheduledFor, + scheduleId, + }, + pluginId, + }); - return row ?? null; - }, + return { action: row.action }; + }), listForItem: async itemId => { const rows = await c @@ -301,6 +320,20 @@ export const createContentSchedulesModel = ({ tx, }); + await recordContentAudit(c, tx, { + action: "scheduled", + actor: resolveContentActor(c), + definition, + itemId, + metadata: { + ...(previous ? { replacedPending: true } : {}), + scheduleAction: action, + scheduledFor, + scheduleId: row.id, + }, + pluginId, + }); + return { generation, id: row.id, scheduledFor }; }); }, diff --git a/packages/vitnode/src/content/server/service.test.ts b/packages/vitnode/src/content/server/service.test.ts index 3f537ab901..e61a0d21b0 100644 --- a/packages/vitnode/src/content/server/service.test.ts +++ b/packages/vitnode/src/content/server/service.test.ts @@ -6,6 +6,7 @@ import { PgDialect } from "drizzle-orm/pg-core"; import { describe, expect, it } from "vitest"; import { ZodError } from "zod"; +import { createAuditRecorder } from "@/tests/audit-recorder"; import { testArticleContentType, testCategoryContentType, @@ -86,10 +87,17 @@ const createDbMock = ( return chain(queue.shift() ?? []); }; + // Audit entries are kept apart from the scripted statements: they are + // written inside every audited write, and asserted on their own. + const audit = createAuditRecorder(); + const db = { delete: start("delete"), - insert: start("insert"), + insert: (table: unknown) => audit.insert(table) ?? start("insert")(table), select: start("select"), + transaction: async ( + body: (tx: unknown) => Promise, + ): Promise => await body(db), update: start("update"), }; @@ -97,7 +105,7 @@ const createDbMock = ( get: (key: string) => (key === "db" ? db : variables[key]), } as Context; - return { c, calls }; + return { audits: audit.entries, c, calls }; }; const opsOf = (calls: RecordedCall[], op: string) => @@ -252,6 +260,83 @@ describe("content service", () => { }); }); + describe("audit", () => { + const auditedNotes = createContentModel( + defineContentType({ + id: "test.audited-note", + tableName: "test_audited_notes", + fields: { + title: field.text({ required: true }), + body: field.textarea({ nullable: true }), + views: field.number({ integer: true, defaultValue: 0 }), + }, + audit: { excludeFields: ["views"] }, + admin: { titleField: "title" }, + }), + ); + + it("records content.updated with bounded before/after values, without excluded fields", async () => { + const long = "x".repeat(600); + const { audits, c } = createDbMock( + [ + [{ body: null, id: 7, title: "Old", views: 1 }], + [{ body: long, id: 7, title: "New", views: 2 }], + ], + { admin: { user: { id: 4, name: "Editor" } } }, + ); + + await auditedNotes + .service(c) + .update(7, { body: long, title: "New", views: 2 }); + + expect(audits).toEqual([ + expect.objectContaining({ + action: "content.updated", + actorId: 4, + actorType: "staff", + changes: { + body: { from: null, to: `${"x".repeat(500)}…` }, + title: { from: "Old", to: "New" }, + }, + contentTypeId: "test.audited-note", + metadata: { changedFields: ["title", "body"] }, + targetId: "7", + targetLabel: "New", + targetType: "test.audited-note", + }), + ]); + }); + + it("records nothing when nothing moved", async () => { + const { audits, c } = createDbMock([ + [{ body: null, id: 7, title: "Same", views: 1 }], + ]); + + await auditedNotes.service(c).update(7, { title: "Same" }); + + expect(audits).toEqual([]); + }); + + it("records nothing for a content type with audit: false", async () => { + const silent = createContentModel( + defineContentType({ + id: "test.silent", + tableName: "test_silent", + fields: { title: field.text({ required: true }) }, + audit: false, + }), + ); + const { audits, c } = createDbMock([ + [{ id: 7, title: "Old" }], + [{ id: 7, title: "New" }], + ]); + + await silent.service(c).update(7, { title: "New" }); + + expect(audits).toEqual([]); + }); + }); + describe("delete", () => { it("returns the deleted row", async () => { const { c } = createDbMock([[{ id: 7, title: "Hello" }]]); diff --git a/packages/vitnode/src/content/server/service.ts b/packages/vitnode/src/content/server/service.ts index d47fe615f5..9616b62bd4 100644 --- a/packages/vitnode/src/content/server/service.ts +++ b/packages/vitnode/src/content/server/service.ts @@ -10,6 +10,7 @@ import type { Context } from "hono"; import { and, eq, inArray, ne, or, sql } from "drizzle-orm"; import type { PaginationCursorColumn } from "../../api/lib/with-pagination"; +import type { AuditChanges } from "../../lib/audit/const"; import type { ContentId, ContentIdOf } from "../ids"; import type { ContentSchemas } from "../schemas"; import type { @@ -36,6 +37,7 @@ import type { } from "../types"; import type { ContentVisibilityAction } from "../visibility"; import type { ContentAdvancedStore } from "./advanced-store"; +import type { ContentAuditAction } from "./audit"; import type { ContentDuplicateTranslationSource, ContentDuplicationMethods, @@ -65,6 +67,15 @@ import { contentRelationStrategy, requireContentId } from "../ids"; import { partitionContentFields } from "../localization"; import { contentColumnsToValues, contentStorageColumns } from "../paths"; import { orderableColumns } from "../registry"; +import { resolveContentActor } from "./actor"; +import { + contentAuditChanges, + contentAuditLabel, + contentPluginIdOf, + recordContentAudit, + resolveContentAuditLabel, + withContentDuplicateAudit, +} from "./audit"; import { buildContentRelationOperations, buildContentRepeatableOperations, @@ -562,6 +573,72 @@ export const createContentService = < const db = (options?: ContentServiceOptions): ContentDatabase => options?.tx ?? c.get("db"); + // An editorial content type is audited by its revision captures, so only a + // content type without one is audited here - never both. + const auditing = definition.audit.enabled && !definition.editorial.enabled; + + const auditLabel = async ( + tx: ContentDatabase, + itemId: ContentId, + row: null | Record, + ): Promise => + await resolveContentAuditLabel( + definition, + row, + translation?.model + ? async () => + await translation + .model?.() + .findByLocale( + itemId as ContentIdOf, + definition.localization.defaultLocale, + { tx }, + ) + : undefined, + ); + + /** Records one plain write, in its transaction. */ + const audit = async ( + tx: ContentDatabase, + { + action, + changedFields, + changes, + itemId, + label, + row, + }: { + action: ContentAuditAction; + changedFields?: readonly string[]; + changes?: AuditChanges | null; + itemId: ContentId; + label?: null | string; + row: null | Record; + }, + ): Promise => { + if (!auditing) return; + + await recordContentAudit(c, tx, { + action, + actor: resolveContentActor(c), + changedFields, + changes, + definition, + itemId, + label: label === undefined ? await auditLabel(tx, itemId, row) : label, + pluginId: contentPluginIdOf(c, definition), + }); + }; + + /** A transaction whenever the write is audited, so the entry is atomic with it. */ + const auditedTransaction = async ( + options: ContentServiceOptions | undefined, + body: (tx: ContentDatabase) => Promise, + ): Promise => + options?.tx || !auditing + ? await body(db(options)) + : await c.get("db").transaction(async tx => await body(tx)); + const ownSelection = (): Record => Object.fromEntries(ownColumnNames.map(name => [name, columns[name]])); @@ -738,32 +815,38 @@ export const createContentService = < options: ContentServiceOptions | undefined, values: Record, guard: SQL, - ): Promise | null> => { - const database = db(options); + ): Promise | null> => + await auditedTransaction(options, async database => { + const [row] = await database + .update(table) + .set(values) + .where(and(eq(primaryCursor, id), guard)) + .returning(ownSelection()); - const [row] = await database - .update(table) - .set(values) - .where(and(eq(primaryCursor, id), guard)) - .returning(ownSelection()); + if (row) { + await audit(database, { + action: values.status === "published" ? "published" : "unpublished", + itemId: id, + row, + }); - if (row) - return { - changed: true, - publishedAt: publishedAtOf(row), - row: toRow(row), - }; + return { + changed: true, + publishedAt: publishedAtOf(row), + row: toRow(row), + }; + } - const current = await readOne(id, database); + const current = await readOne(id, database); - return current - ? { - changed: false, - publishedAt: publishedAtOf(current), - row: toRow(current), - } - : null; - }; + return current + ? { + changed: false, + publishedAt: publishedAtOf(current), + row: toRow(current), + } + : null; + }); const publish = async ( id: ContentId, @@ -857,6 +940,12 @@ export const createContentService = < .returning(ownSelection()); if (!row) return null; + await audit(tx, { + action: action === "hide" ? "hidden" : "unhidden", + itemId: id, + row, + }); + return { changed: true, ...visibilityStateOf(row), @@ -889,7 +978,8 @@ export const createContentService = < body: (tx: ContentDatabase) => Promise, ): Promise => { if (options?.tx) return await body(options.tx); - if (!store?.enabled) return await body(c.get("db")); + // An audited write opens one too: its entry has to commit with it. + if (!store?.enabled && !auditing) return await body(c.get("db")); return await c.get("db").transaction(async tx => await body(tx)); }; @@ -977,39 +1067,58 @@ export const createContentService = < // In the same transaction as the row it belongs to: a create that // committed its categories and rolled back its article would leave // junction rows pointing at nothing. - if (store?.enabled) { - await store.write( - tx, - requireContentId(definition.idStrategy, row.id, contentTypeId), - parsed, - ); - } + const itemId = requireContentId( + definition.idStrategy, + row.id, + contentTypeId, + ); + if (store?.enabled) await store.write(tx, itemId, parsed); + + await audit(tx, { + action: "created", + changedFields: Object.keys(parsed), + itemId, + // Never the translation's: a record's translations are written after it. + label: contentAuditLabel(definition, row), + row, + }); return toRow(row); }), delete: async (id, options) => { const remove = async (database: ContentDatabase) => { + // Read first: a localized title goes with the translation rows. + const translated = + auditing && definition.localization.enabled + ? await auditLabel(database, id, null) + : null; const [row] = await database .delete(table) .where(eq(primaryCursor, id)) .returning(ownSelection()); - if (row) { - await cancelContentSubmissions(database, definition, id); - await recordContentNotificationCleanup(c, database, { - definition, - itemId: id, - }); - } + if (!row) return null; + + await cancelContentSubmissions(database, definition, id); + await recordContentNotificationCleanup(c, database, { + definition, + itemId: id, + }); + await audit(database, { + action: "deleted", + itemId: id, + label: translated ?? contentAuditLabel(definition, row), + row, + }); - return row ? toRow(row) : null; + return toRow(row); }; // The cleanup intent must commit with the delete, so a type with // content notifications always deletes in a transaction. return hasContentNotificationDependents(c, definition) ? await inTransaction(options, remove) - : await remove(db(options)); + : await auditedTransaction(options, remove); }, findById: async (id, options) => { @@ -1330,6 +1439,20 @@ export const createContentService = < .where(eq(primaryCursor, id)) .returning(ownSelection()); + await audit(tx, { + action: "updated", + changedFields, + changes: auditing + ? contentAuditChanges(definition, { + after: projectRow(row), + before: projectRow(current), + changedFields: changedPaths, + }) + : null, + itemId: id, + row, + }); + return { changedFields: changedFields as ContentChangedPath[], row: toRow(row), @@ -1427,52 +1550,61 @@ export const createContentService = < const duplicationMethods: ContentDuplicationMethods = { duplicate: async (sourceId, options = {}) => { const translationModel = translation?.model; - const result = await runContentDuplicate( - { - advanced: store, - c, - columns, - definition, - table, - translation: - translation && translationModel - ? { - columns: translation.columns, - model: - translationModel as unknown as () => ContentDuplicateTranslationSource, - table: translation.table, - } - : undefined, - }, + const result = await withContentDuplicateAudit( + c, + definition, sourceId, - options, - { - base: async (values, tx) => - await service.create(values as ContentCreateInput, { - tx, - }), - idOf: row => - requireContentId( - definition.idStrategy, - (row as { id: unknown }).id, - contentTypeId, - ), - translation: async (itemId, locale, values, tx) => { - if (!translationModel) { - throw new ContentEngineError( - "Duplicating a translation needs the translation model.", - { contentTypeId }, - ); - } + async () => + await runContentDuplicate( + { + advanced: store, + c, + columns, + definition, + table, + translation: + translation && translationModel + ? { + columns: translation.columns, + model: + translationModel as unknown as () => ContentDuplicateTranslationSource, + table: translation.table, + } + : undefined, + }, + sourceId, + options, + { + base: async (values, tx) => + await service.create( + values as ContentCreateInput, + { + tx, + }, + ), + idOf: row => + requireContentId( + definition.idStrategy, + (row as { id: unknown }).id, + contentTypeId, + ), + translation: async (itemId, locale, values, tx) => { + if (!translationModel) { + throw new ContentEngineError( + "Duplicating a translation needs the translation model.", + { contentTypeId }, + ); + } - return await translationModel().create( - itemId as ContentIdOf, - locale, - values as ContentLocalizedValues, - { tx }, - ); - }, - }, + return await translationModel().create( + itemId as ContentIdOf, + locale, + values as ContentLocalizedValues, + { tx }, + ); + }, + }, + ), ); return result diff --git a/packages/vitnode/src/content/server/translation-editorial-service.ts b/packages/vitnode/src/content/server/translation-editorial-service.ts index 1a820774be..e1ce4b8988 100644 --- a/packages/vitnode/src/content/server/translation-editorial-service.ts +++ b/packages/vitnode/src/content/server/translation-editorial-service.ts @@ -36,6 +36,11 @@ import { contentInnerFields, splitContentFieldPath, } from "../paths"; +import { + CONTENT_TRANSLATION_REVISION_AUDIT_ACTIONS, + contentAuditLabel, + recordContentAudit, +} from "./audit"; import { applyContentDeliveryWrite, contentSlugHistoryFor, @@ -342,8 +347,8 @@ export const createContentTranslationEditorialService = < row: ContentTranslationRow; version: number; }, - ): Promise => - await revisionsFor(languageId).capture(tx, { + ): Promise => { + const revisionId = await revisionsFor(languageId).capture(tx, { actor, changedFields, itemId: row.itemId, @@ -357,6 +362,29 @@ export const createContentTranslationEditorialService = < version, }); + // In the transaction, beside the revision it points at - see the shared + // service's `capture`. + await recordContentAudit(c, tx, { + action: CONTENT_TRANSLATION_REVISION_AUDIT_ACTIONS[operation], + actor, + changedFields, + definition, + itemId: row.itemId, + label: contentAuditLabel(definition, row), + locale, + metadata: { + version, + ...(restoredFromRevisionId === undefined + ? {} + : { restoredFromRevisionId }), + }, + pluginId, + revisionId, + }); + + return revisionId; + }; + const unchanged = ( operation: ContentTranslationRevisionOperation, row: ContentTranslationRow, diff --git a/packages/vitnode/src/content/server/translation-model.ts b/packages/vitnode/src/content/server/translation-model.ts index 96b860a98e..0fa0336226 100644 --- a/packages/vitnode/src/content/server/translation-model.ts +++ b/packages/vitnode/src/content/server/translation-model.ts @@ -4,6 +4,7 @@ import type { Context } from "hono"; import { and, asc, eq, inArray, ne, sql } from "drizzle-orm"; +import type { AuditChanges } from "../../lib/audit/const"; import type { ContentId, ContentIdOf } from "../ids"; import type { ContentTranslationSchemas } from "../schemas"; import type { @@ -14,6 +15,7 @@ import type { ContentTranslationMeta, ContentTranslationRow, } from "../types"; +import type { ContentAuditAction } from "./audit"; import type { ContentLanguage } from "./language-resolver"; import type { ContentDatabase } from "./service"; @@ -34,6 +36,13 @@ import { contentStorageColumns, contentValuesToColumns, } from "../paths"; +import { resolveContentActor } from "./actor"; +import { + contentAuditChanges, + contentAuditLabel, + contentPluginIdOf, + recordContentAudit, +} from "./audit"; import { contentDatabase, findContentLanguage, @@ -430,7 +439,44 @@ export const createContentTranslationModel = < } }; - return { + // A translation of an editorial content type is audited by its revision + // capture; only a revisionless one is audited here. + const auditing = definition.audit.enabled && !definition.editorial.enabled; + + const audited = async ( + options: ContentTranslationOptions | undefined, + body: (tx: ContentDatabase) => Promise, + ): Promise => + options?.tx + ? await body(options.tx) + : await c.get("db").transaction(async tx => await body(tx)); + + const audit = async ( + tx: ContentDatabase, + action: ContentAuditAction, + row: ContentTranslationRow, + { + changedFields, + changes, + }: { + changedFields?: readonly string[]; + changes?: AuditChanges | null; + } = {}, + ): Promise => { + await recordContentAudit(c, tx, { + action, + actor: resolveContentActor(c), + changedFields, + changes, + definition, + itemId: row.itemId, + label: contentAuditLabel(definition, row), + locale: row.locale, + pluginId: contentPluginIdOf(c, definition), + }); + }; + + const methods: ContentTranslationModel = { create: async (itemId, locale, values, options) => { const target = await language(locale, { requireEnabled: true, @@ -786,14 +832,83 @@ export const createContentTranslationModel = < await touchItem(itemId, database); + const updated = toRow(row, target.locale); + if (auditing) { + await audit(database, "translation_updated", updated, { + changedFields, + changes: contentAuditChanges(definition, { + after: updated.values, + before: toRow(current, target.locale).values, + changedFields, + scope: "localized", + }), + }); + } + return { changed: true, changedFields, - row: toRow(row, target.locale), + row: updated, version: versionOf(row), }; }, }; + if (!auditing) return methods; + + return { + ...methods, + create: async (itemId, locale, values, options) => + await audited(options, async tx => { + const row = await methods.create(itemId, locale, values, { + ...options, + tx, + }); + await audit(tx, "translation_created", row, { + changedFields: Object.keys(row.values), + }); + + return row; + }), + delete: async (itemId, locale, options) => + await audited(options, async tx => { + const row = await methods.delete(itemId, locale, { ...options, tx }); + if (row) await audit(tx, "translation_deleted", row); + + return row; + }), + publish: async (itemId, locale, options) => + await audited(options, async tx => { + const result = await methods.publish(itemId, locale, { + ...options, + tx, + }); + if (result?.changed) { + await audit(tx, "translation_published", result.row); + } + + return result; + }), + unpublish: async (itemId, locale, options) => + await audited(options, async tx => { + const result = await methods.unpublish(itemId, locale, { + ...options, + tx, + }); + if (result?.changed) { + await audit(tx, "translation_unpublished", result.row); + } + + return result; + }), + // Audited where the row is read and written, so the entry costs no extra + // query; this only supplies the transaction. + update: async (itemId, locale, values, options) => + await audited( + options, + async tx => + await methods.update(itemId, locale, values, { ...options, tx }), + ), + }; }; /** The language registry keyed by id, for turning a stored FK back into a locale. */ diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts index 42bdd3c9f6..3b66f5dc7b 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -2035,6 +2035,33 @@ export interface ContentLeafColumn { path: string; } +/** + * How the Content Engine records a content type's writes in the central audit + * log. Present and on by default; `audit: false` turns it off. + */ +export interface ContentAuditInput { + /** + * Fields whose changes are never named or diffed in an audit entry - + * counters, cached totals, anything that changes on every write. The write + * itself is still recorded. Sensitive fields need no entry: they are always + * redacted. + */ + excludeFields?: readonly TFieldName[]; + /** + * Record which fields changed (and, without `editorial`, their bounded + * before/after values). Defaults to `true`; `false` records the operation + * only. + */ + trackChanges?: boolean; +} + +/** {@link ContentAuditInput} with every default applied. */ +export interface ResolvedContentAuditConfig { + enabled: boolean; + excludeFields: string[]; + trackChanges: boolean; +} + /** * A **resolved** content type, as returned by `defineContentType()`. * @@ -2067,6 +2094,8 @@ export interface ContentTypeDefinition< admin: ResolvedContentAdminConfig; /** Generated junction tables, child tables and the leaf-path mapping. */ advanced: ResolvedContentAdvancedConfig; + /** Automatic audit logging of the content type's writes. */ + audit: ResolvedContentAuditConfig; delivery: ResolvedContentDeliveryConfig; duplication: ResolvedContentDuplicationConfig; diff --git a/packages/vitnode/src/database/audit.ts b/packages/vitnode/src/database/audit.ts new file mode 100644 index 0000000000..ebd1217b6d --- /dev/null +++ b/packages/vitnode/src/database/audit.ts @@ -0,0 +1,99 @@ +import { camelCase, index } from "drizzle-orm/pg-core"; + +import type { AuditChanges } from "@/lib/audit/const"; + +import { + AUDIT_ACTION_MAX_LENGTH, + AUDIT_ACTOR_TYPES, + AUDIT_AREAS, + AUDIT_DEFAULT_RETENTION_DAYS, + AUDIT_PLUGIN_ID_MAX_LENGTH, + AUDIT_SCOPE_FAMILY_MAX_LENGTH, + AUDIT_SCOPE_ID_MAX_LENGTH, + AUDIT_TARGET_ID_MAX_LENGTH, + AUDIT_TARGET_LABEL_MAX_LENGTH, + AUDIT_TARGET_TYPE_MAX_LENGTH, +} from "@/lib/audit/const"; + +import { core_users } from "./users"; + +/** + * The append-only history of administrative, moderation, plugin and Content + * Engine operations - who did what, when, to which resource. + * + * Deliberately separate from `core_logs` (technical warnings and errors) and + * from `core_content_revisions` (what a record's content used to be). Nothing + * here cascades from the resources it describes: `pluginId`, `targetType`, + * `targetId` and `targetLabel` are plain text, so an entry outlives the record, + * the content type and the plugin it names. Only the actor is a foreign key, + * set to `null` when the account is deleted; `actorName` keeps the name the + * entry was written under. + */ +export const core_audit_logs = camelCase.table.withRLS( + "core_audit_logs", + t => ({ + id: t.bigserial({ mode: "number" }).primaryKey(), + pluginId: t.varchar({ length: AUDIT_PLUGIN_ID_MAX_LENGTH }).notNull(), + area: t.varchar({ enum: AUDIT_AREAS, length: 16 }).notNull(), + action: t.varchar({ length: AUDIT_ACTION_MAX_LENGTH }).notNull(), + actorType: t.varchar({ enum: AUDIT_ACTOR_TYPES, length: 16 }).notNull(), + actorId: t.integer().references(() => core_users.id, { + onDelete: "set null", + onUpdate: "cascade", + }), + actorName: t.varchar({ length: 255 }), + targetType: t.varchar({ length: AUDIT_TARGET_TYPE_MAX_LENGTH }).notNull(), + targetId: t.varchar({ length: AUDIT_TARGET_ID_MAX_LENGTH }), + targetLabel: t.varchar({ length: AUDIT_TARGET_LABEL_MAX_LENGTH }), + changes: t.jsonb().$type(), + metadata: t.jsonb().$type>().notNull().default({}), + contentTypeId: t.varchar({ length: 100 }), + /** Not a foreign key: revisions are pruned by their own retention. */ + revisionId: t.integer(), + scopeFamily: t.varchar({ length: AUDIT_SCOPE_FAMILY_MAX_LENGTH }), + scopeId: t.varchar({ length: AUDIT_SCOPE_ID_MAX_LENGTH }), + ipAddress: t.varchar({ length: 45 }), + /** One id per request or background run, shared by every entry it wrote. */ + correlationId: t.varchar({ length: 64 }), + createdAt: t.timestamp().notNull().defaultNow(), + }), + t => [ + // Newest first, date ranges and the retention sweep. + index("core_audit_logs_created_at_id_idx").on(t.createdAt, t.id), + index("core_audit_logs_actor_idx").on(t.actorId, t.createdAt), + index("core_audit_logs_plugin_idx").on(t.pluginId, t.createdAt), + index("core_audit_logs_action_idx").on(t.action, t.createdAt), + index("core_audit_logs_area_idx").on(t.area, t.createdAt), + // One entity's history. + index("core_audit_logs_target_idx").on( + t.pluginId, + t.targetType, + t.targetId, + t.createdAt, + ), + // A scoped moderator's view. + index("core_audit_logs_scope_idx").on( + t.scopeFamily, + t.scopeId, + t.createdAt, + ), + ], +); + +/** + * The Activity Logs settings, one row (`id = 1`). Absent until an admin first + * saves them, so every reader falls back to the defaults. + */ +export const core_audit_settings = camelCase.table.withRLS( + "core_audit_settings", + t => ({ + id: t.integer().primaryKey().default(1), + /** Days an entry is kept. `0` keeps everything. */ + retentionDays: t.integer().notNull().default(AUDIT_DEFAULT_RETENTION_DAYS), + updatedAt: t + .timestamp() + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }), +); diff --git a/packages/vitnode/src/database/relations.ts b/packages/vitnode/src/database/relations.ts index 3325dfe1e5..4b60cbb474 100644 --- a/packages/vitnode/src/database/relations.ts +++ b/packages/vitnode/src/database/relations.ts @@ -2,6 +2,7 @@ import { defineRelations } from "drizzle-orm"; import * as admins from "./admins"; import * as ai from "./ai"; +import * as audit from "./audit"; import * as content from "./content"; import * as contentContainers from "./content-containers"; import * as contentSubmissions from "./content-submissions"; @@ -24,6 +25,7 @@ import * as users from "./users"; export const coreSchema = { ...admins, ...ai, + ...audit, ...content, ...contentContainers, ...contentSubmissions, diff --git a/packages/vitnode/src/lib/audit/actions.test.ts b/packages/vitnode/src/lib/audit/actions.test.ts new file mode 100644 index 0000000000..44db39d838 --- /dev/null +++ b/packages/vitnode/src/lib/audit/actions.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { defineAuditActions } from "./actions"; +import { AUDIT_ACTION_MAX_LENGTH } from "./const"; + +describe("defineAuditActions", () => { + it("returns valid declarations unchanged", () => { + const actions = [ + { action: "topic.moved", targetType: "topic" }, + { action: "post.status_changed", targetType: "forum.post" }, + ] as const; + + expect(defineAuditActions(actions)).toBe(actions); + }); + + it.each([ + ["an uppercase action", [{ action: "Topic.Moved", targetType: "topic" }]], + ["a trailing dot", [{ action: "topic.", targetType: "topic" }]], + [ + "an action over the length limit", + [{ action: "a".repeat(AUDIT_ACTION_MAX_LENGTH + 1), targetType: "t" }], + ], + [ + "a target type with spaces", + [{ action: "topic.moved", targetType: "a b" }], + ], + [ + "a duplicate action", + [ + { action: "topic.moved", targetType: "topic" }, + { action: "topic.moved", targetType: "post" }, + ], + ], + ])("throws for %s", (_label, actions) => { + expect(() => defineAuditActions(actions)).toThrow(); + }); +}); diff --git a/packages/vitnode/src/lib/audit/actions.ts b/packages/vitnode/src/lib/audit/actions.ts new file mode 100644 index 0000000000..4d3353b96f --- /dev/null +++ b/packages/vitnode/src/lib/audit/actions.ts @@ -0,0 +1,139 @@ +import { + AUDIT_ACTION_MAX_LENGTH, + AUDIT_ACTION_PATTERN, + AUDIT_TARGET_TYPE_PATTERN, +} from "./const"; + +/** + * One audit action a plugin records, declared so the Activity Logs filters can + * offer it before it has ever happened. + * + * Its label is not part of the declaration: the AdminCP translates + * `{pluginId}.audit.actions.{action}` from the plugin's own locale file (dots + * in the action nest, so `topic.moved` is `audit.actions.topic.moved`), and + * falls back to the action id itself. Nothing a plugin declares runs when an + * entry is rendered, which is what keeps entries readable after the plugin is + * uninstalled. + */ +export interface AuditActionDefinition { + /** `topic.moved` - lowercase words joined by dots, unique within the plugin. */ + action: string; + /** What it acts on: `topic`, `order`, `settings`. */ + targetType: string; +} + +/** + * Declares the audit actions a plugin records with `c.get("audit").record()`. + * Pass the result to `buildApiPlugin({ auditActions })`. Universal. + * + * @throws When an action or target type is not a valid identifier, or an + * action is declared twice. + * + * @example + * ```ts + * import { defineAuditActions } from "@vitnode/core/lib/audit/actions"; + * + * export const forumAuditActions = defineAuditActions([ + * { action: "topic.moved", targetType: "topic" }, + * { action: "topic.locked", targetType: "topic" }, + * ]); + * ``` + */ +export const defineAuditActions = < + const TActions extends readonly AuditActionDefinition[], +>( + actions: TActions, +): TActions => { + const seen = new Set(); + for (const { action, targetType } of actions) { + if ( + action.length > AUDIT_ACTION_MAX_LENGTH || + !AUDIT_ACTION_PATTERN.test(action) + ) { + throw new Error( + `Audit action "${action}" must be lowercase words joined by dots, like "topic.moved".`, + ); + } + if (!AUDIT_TARGET_TYPE_PATTERN.test(targetType)) { + throw new Error( + `Audit target type "${targetType}" of "${action}" must be a lowercase identifier.`, + ); + } + if (seen.has(action)) { + throw new Error(`Audit action "${action}" is declared twice.`); + } + seen.add(action); + } + + return actions; +}; + +/** The Content Engine's actions, recorded under the content type's plugin. */ +export const CONTENT_AUDIT_ACTIONS = [ + "content.created", + "content.updated", + "content.deleted", + "content.duplicated", + "content.published", + "content.unpublished", + "content.hidden", + "content.unhidden", + "content.restored", + "content.scheduled", + "content.schedule_cancelled", + "content.translation_created", + "content.translation_updated", + "content.translation_deleted", + "content.translation_published", + "content.translation_unpublished", + "content.translation_restored", +] as const; + +/** Everything core records under `@vitnode/core`. */ +export const CORE_AUDIT_ACTIONS = defineAuditActions([ + { action: "role.created", targetType: "role" }, + { action: "role.updated", targetType: "role" }, + { action: "role.deleted", targetType: "role" }, + { action: "admin_permissions.created", targetType: "staff_entry" }, + { action: "admin_permissions.updated", targetType: "staff_entry" }, + { action: "admin_permissions.deleted", targetType: "staff_entry" }, + { action: "moderator_permissions.created", targetType: "staff_entry" }, + { action: "moderator_permissions.updated", targetType: "staff_entry" }, + { action: "moderator_permissions.deleted", targetType: "staff_entry" }, + { action: "user.updated", targetType: "user" }, + { action: "user.roles_changed", targetType: "user" }, + { action: "user.password_reset", targetType: "user" }, + { action: "user.email_verified", targetType: "user" }, + { action: "user.device_revoked", targetType: "user" }, + { action: "user.devices_revoked", targetType: "user" }, + { action: "settings.updated", targetType: "settings" }, + { action: "audit_settings.updated", targetType: "settings" }, + { action: "ai_access.role_updated", targetType: "role" }, + { action: "ai_access.user_override_saved", targetType: "user" }, + { action: "ai_access.user_override_removed", targetType: "user" }, + { action: "notifications.type_updated", targetType: "notification_type" }, + { action: "notifications.paused", targetType: "settings" }, + { action: "notifications.resumed", targetType: "settings" }, + { action: "notifications.emails_cancelled", targetType: "settings" }, + { action: "notifications.all_marked_read", targetType: "settings" }, + { action: "notifications.all_deleted", targetType: "settings" }, + { action: "notifications.member_preferences_reset", targetType: "settings" }, + { action: "report_reason.created", targetType: "report_reason" }, + { action: "report_reason.updated", targetType: "report_reason" }, + { action: "report_reason.deleted", targetType: "report_reason" }, + { action: "report_reason.reordered", targetType: "report_reason" }, + { action: "report.claimed", targetType: "report" }, + { action: "report.assigned", targetType: "report" }, + { action: "report.unassigned", targetType: "report" }, + { action: "report.note_added", targetType: "report" }, + { action: "report.note_edited", targetType: "report" }, + { action: "report.note_deleted", targetType: "report" }, + { action: "report.resolved", targetType: "report" }, + { action: "report.reopened", targetType: "report" }, + { action: "report.action_taken", targetType: "report" }, + { action: "submission.approved", targetType: "submission" }, + { action: "submission.rejected", targetType: "submission" }, + { action: "cron.run", targetType: "cron_job" }, + { action: "audit_logs.pruned", targetType: "settings" }, + { action: "audit_logs.exported", targetType: "settings" }, +] as const); diff --git a/packages/vitnode/src/lib/audit/const.ts b/packages/vitnode/src/lib/audit/const.ts new file mode 100644 index 0000000000..a811381de7 --- /dev/null +++ b/packages/vitnode/src/lib/audit/const.ts @@ -0,0 +1,109 @@ +/** + * Where an audited operation was performed: the AdminCP, the ModeratorCP (or a + * moderator tool on the public site), a member's own write on the public site, + * or a background task with nobody behind it. + */ +export const AUDIT_AREAS = ["admin", "moderator", "public", "system"] as const; + +export type AuditArea = (typeof AUDIT_AREAS)[number]; + +/** + * The two staff logs of the AdminCP: moderators' (the `moderator` area) and + * admins' (every other area, system and member activity included). + */ +export const AUDIT_STAFF_LOGS = ["admin", "moderator"] as const; + +export type AuditStaffLog = (typeof AUDIT_STAFF_LOGS)[number]; + +/** + * Who performed an audited operation: a staff member (admin or moderator), a + * signed-in member writing through the public API, or the system itself. The + * same vocabulary the Content Engine uses for its revisions. + */ +export const AUDIT_ACTOR_TYPES = ["staff", "api", "system"] as const; + +export type AuditActorType = (typeof AUDIT_ACTOR_TYPES)[number]; + +/** The plugin id core records its own operations under. */ +export const AUDIT_CORE_PLUGIN_ID = "@vitnode/core"; + +export const AUDIT_PLUGIN_ID_MAX_LENGTH = 255; +export const AUDIT_ACTION_MAX_LENGTH = 150; +export const AUDIT_TARGET_TYPE_MAX_LENGTH = 100; +export const AUDIT_TARGET_ID_MAX_LENGTH = 64; +export const AUDIT_TARGET_LABEL_MAX_LENGTH = 255; +export const AUDIT_SCOPE_FAMILY_MAX_LENGTH = 100; +export const AUDIT_SCOPE_ID_MAX_LENGTH = 64; + +/** + * Stable, lowercase, dot-separated identifiers: `category.updated`, + * `report.status_changed`, `content.translation_published`. + */ +export const AUDIT_ACTION_PATTERN = /^[a-z0-9_]+(?:\.[a-z0-9_]+)*$/; + +/** + * A target type uses the same alphabet: `category`, `report`, or a content + * type id such as `blog.category`. + */ +export const AUDIT_TARGET_TYPE_PATTERN = /^[a-z0-9_-]+(?:\.[a-z0-9_-]+)*$/; + +/** Longest string a change or metadata value keeps before it is cut. */ +export const AUDIT_STRING_MAX_LENGTH = 500; +/** Most fields one `changes` object keeps. */ +export const AUDIT_CHANGES_MAX_FIELDS = 50; +/** Most keys one metadata object (at any depth) keeps. */ +export const AUDIT_OBJECT_MAX_KEYS = 50; +/** Most items an array keeps. */ +export const AUDIT_ARRAY_MAX_ITEMS = 50; +/** How deep a nested value is followed before it is summarised. */ +export const AUDIT_MAX_DEPTH = 4; +/** Serialized budget of `changes`, and separately of `metadata`, in bytes. */ +export const AUDIT_JSON_MAX_BYTES = 16_384; + +/** What a value is replaced with when it must never be stored. */ +export const AUDIT_REDACTED = "[redacted]"; +/** What a value is replaced with when it is too large to be worth storing. */ +export const AUDIT_TRUNCATED = "[truncated]"; +/** What a rich-text document is stored as: the revision has the document. */ +export const AUDIT_RICH_TEXT = "[rich text]"; + +/** Retention choices offered in the AdminCP, in days. `0` keeps everything. */ +export const AUDIT_RETENTION_DAYS_OPTIONS = [0, 30, 90, 180, 365, 730] as const; +export const AUDIT_DEFAULT_RETENTION_DAYS = 365; +/** Rows the retention job deletes per statement, so a backlog never locks the table. */ +export const AUDIT_RETENTION_BATCH_SIZE = 5_000; +/** Most rows one CSV export contains. */ +export const AUDIT_EXPORT_MAX_ROWS = 10_000; + +/** A single before/after transition of one field. */ +export interface AuditChange { + from?: unknown; + /** + * `true` when the field is sensitive: the change is recorded, its values are + * not. + */ + redacted?: true; + to?: unknown; +} + +/** Structured before/after changes, keyed by field name. */ +export type AuditChanges = Record; + +/** The scope a moderation operation belongs to, as the moderation registry names it. */ +export interface AuditScope { + family: string; + id: number | string; +} + +/** The resource an audited operation acted on. */ +export interface AuditTarget { + /** Numeric and string ids are both stored as text. `null` for a singleton (settings). */ + id?: null | number | string; + /** + * A short human-readable name, kept so the entry still reads well after the + * resource is deleted. Not personal data beyond what the resource shows. + */ + label?: null | string; + /** `category`, `role`, `report`, `settings`... */ + type: string; +} diff --git a/packages/vitnode/src/lib/audit/csv.test.ts b/packages/vitnode/src/lib/audit/csv.test.ts new file mode 100644 index 0000000000..15d3424d07 --- /dev/null +++ b/packages/vitnode/src/lib/audit/csv.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { csvCell, toCsv } from "./csv"; + +describe("csvCell", () => { + it.each([ + ['=HYPERLINK("http://x")', `"'=HYPERLINK(""http://x"")"`], + ["+1", `"'+1"`], + ["-2", `"'-2"`], + ["@SUM(A1)", `"'@SUM(A1)"`], + ["\tcmd", `"'\tcmd"`], + ["\rcmd", `"'\rcmd"`], + ['say "hi"', `"say ""hi"""`], + ["plain", `"plain"`], + [null, `""`], + [undefined, `""`], + [42, `"42"`], + [false, `"false"`], + [new Date("2026-01-02T00:00:00.000Z"), `"2026-01-02T00:00:00.000Z"`], + [{ a: "b" }, `"{""a"":""b""}"`], + ])("%j -> %s", (value, cell) => { + expect(csvCell(value)).toBe(cell); + }); +}); + +describe("toCsv", () => { + it("writes the header first and joins rows with CRLF", () => { + expect( + toCsv( + ["id", "label"], + [ + [1, "A"], + [2, "=B"], + ], + ), + ).toBe(`"id","label"\r\n"1","A"\r\n"2","'=B"`); + }); +}); diff --git a/packages/vitnode/src/lib/audit/csv.ts b/packages/vitnode/src/lib/audit/csv.ts new file mode 100644 index 0000000000..06d0360cfa --- /dev/null +++ b/packages/vitnode/src/lib/audit/csv.ts @@ -0,0 +1,33 @@ +/** Characters a spreadsheet reads as the start of a formula. */ +const FORMULA_START = /^[=+\-@\t\r]/; + +/** + * One CSV cell: `null` and `undefined` are empty, objects are JSON, and a + * value a spreadsheet would run as a formula (`=HYPERLINK(...)`, `+1`, `@SUM`) + * is prefixed with `'` so it is shown, not evaluated. Always quoted. + */ +export const csvCell = (value: unknown): string => { + if (value === null || value === undefined) return '""'; + + let text: string; + if (value instanceof Date) text = value.toISOString(); + else if (typeof value === "string") text = value; + else if ( + typeof value === "number" || + typeof value === "boolean" || + typeof value === "bigint" + ) { + text = value.toString(); + } else text = JSON.stringify(value) ?? ""; + + if (FORMULA_START.test(text)) text = `'${text}`; + + return `"${text.replaceAll('"', '""')}"`; +}; + +/** A CSV document - header row first, CRLF line endings (RFC 4180). */ +export const toCsv = ( + header: readonly string[], + rows: readonly (readonly unknown[])[], +): string => + [header, ...rows].map(row => row.map(csvCell).join(",")).join("\r\n"); diff --git a/packages/vitnode/src/lib/audit/diff.test.ts b/packages/vitnode/src/lib/audit/diff.test.ts new file mode 100644 index 0000000000..f9665210bc --- /dev/null +++ b/packages/vitnode/src/lib/audit/diff.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; + +import { AUDIT_RICH_TEXT } from "./const"; +import { auditDiff } from "./diff"; + +const doc = (text: string) => ({ + content: [{ content: [{ text, type: "text" }], type: "paragraph" }], + type: "doc", +}); + +describe("auditDiff", () => { + it("leaves unchanged fields out and keeps transitions to and from null", () => { + expect( + auditDiff({ + after: { color: "#fff", name: "News", parentId: null }, + before: { color: null, name: "News", parentId: 3 }, + }), + ).toEqual({ + color: { from: null, to: "#fff" }, + parentId: { from: 3, to: null }, + }); + }); + + it("counts a field missing on one side as null", () => { + expect(auditDiff({ after: { name: "News" }, before: null })).toEqual({ + name: { from: null, to: "News" }, + }); + }); + + it("is null when nothing moved", () => { + expect( + auditDiff({ + after: { tags: [1, 2], updated: new Date("2026-01-01T00:00:00Z") }, + before: { tags: [1, 2], updated: new Date("2026-01-01T00:00:00Z") }, + }), + ).toBeNull(); + }); + + it("compares only the included fields and skips the excluded ones", () => { + const before = { name: "A", slug: "a", updatedAt: 1 }; + const after = { name: "B", slug: "b", updatedAt: 2 }; + + expect(auditDiff({ after, before, include: ["name"] })).toEqual({ + name: { from: "A", to: "B" }, + }); + expect(auditDiff({ after, before, exclude: ["updatedAt"] })).toEqual({ + name: { from: "A", to: "B" }, + slug: { from: "a", to: "b" }, + }); + }); + + it("records a sensitive field without its values", () => { + expect( + auditDiff({ + after: { passwordHash: "new" }, + before: { passwordHash: "old" }, + }), + ).toEqual({ passwordHash: { redacted: true } }); + }); + + it("stores a changed rich-text document as a marker, and an unchanged one not at all", () => { + expect( + auditDiff({ + after: { body: doc("new"), intro: doc("same") }, + before: { body: doc("old"), intro: doc("same") }, + }), + ).toEqual({ body: { from: AUDIT_RICH_TEXT, to: AUDIT_RICH_TEXT } }); + }); + + it("serializes dates in a recorded change", () => { + expect( + auditDiff({ + after: { publishedAt: new Date("2026-02-01T00:00:00.000Z") }, + before: { publishedAt: null }, + }), + ).toEqual({ + publishedAt: { from: null, to: "2026-02-01T00:00:00.000Z" }, + }); + }); +}); diff --git a/packages/vitnode/src/lib/audit/diff.ts b/packages/vitnode/src/lib/audit/diff.ts new file mode 100644 index 0000000000..ee101743b0 --- /dev/null +++ b/packages/vitnode/src/lib/audit/diff.ts @@ -0,0 +1,106 @@ +import type { AuditChanges } from "./const"; + +import { AUDIT_RICH_TEXT } from "./const"; +import { isSensitiveAuditKey, sanitizeAuditChanges } from "./sanitize"; + +/** Arguments of {@link auditDiff}. */ +export interface AuditDiffArgs { + /** The values after the operation. `null` for a deletion. */ + after: null | Partial>; + /** The values before the operation. `null` for a creation. */ + before: null | Partial>; + /** + * Fields never compared - high-frequency counters, timestamps, derived + * columns. Sensitive fields need no entry here: they are always redacted. + */ + exclude?: readonly NoInfer[]; + /** + * Compare only these fields. Prefer it for security-relevant writes: an + * allowlist cannot leak a column added later. + */ + include?: readonly NoInfer[]; +} + +const isRichText = (value: unknown): boolean => + typeof value === "object" && + value !== null && + (value as { type?: unknown }).type === "doc" && + Array.isArray((value as { content?: unknown }).content); + +const comparable = (value: unknown): unknown => { + if (value instanceof Date) return value.toISOString(); + + return value; +}; + +const sameValue = (before: unknown, after: unknown): boolean => { + const left = comparable(before); + const right = comparable(after); + if (left === right) return true; + if (left === null || right === null) return false; + if (typeof left !== "object" || typeof right !== "object") return false; + + return JSON.stringify(left) === JSON.stringify(right); +}; + +/** + * Structured before/after changes between two plain objects, ready for + * `c.get("audit").record({ changes })`. Universal and pure. + * + * - Unchanged fields are left out; a transition to or from `null` is kept. + * - A field missing on one side counts as `null` on that side. + * - Sensitive fields (passwords, tokens, secrets, keys...) are recorded as + * `{ redacted: true }` - that they changed, never their values. + * - Rich-text documents are compared by their serialized form, never + * diffed node by node, and stored as a marker: their history belongs to + * Content Revisions, not to audit logs. + * - The result is bounded by the same limits as every audit payload. + * + * @returns `null` when nothing changed. + * + * @example + * ```ts + * import { auditDiff } from "@vitnode/core/lib/audit/diff"; + * + * auditDiff({ + * before: { name: "News", slug: "news" }, + * after: { name: "Announcements", slug: "announcements" }, + * }); + * // { name: { from: "News", to: "Announcements" }, + * // slug: { from: "news", to: "announcements" } } + * ``` + */ +export const auditDiff = ({ + after, + before, + exclude = [], + include, +}: AuditDiffArgs): AuditChanges | null => { + const left = (before ?? {}) as Record; + const right = (after ?? {}) as Record; + const excluded = new Set(exclude); + const fields = + include ?? + ([...new Set([...Object.keys(left), ...Object.keys(right)])] as string[]); + + const changes: AuditChanges = {}; + for (const field of fields) { + if (excluded.has(field)) continue; + const from = left[field] ?? null; + const to = right[field] ?? null; + + if (isRichText(from) || isRichText(to)) { + if (JSON.stringify(from) !== JSON.stringify(to)) { + changes[field] = { from: AUDIT_RICH_TEXT, to: AUDIT_RICH_TEXT }; + } + continue; + } + if (sameValue(from, to)) continue; + + changes[field] = isSensitiveAuditKey(field) + ? { redacted: true } + : { from, to }; + } + + return sanitizeAuditChanges(changes); +}; diff --git a/packages/vitnode/src/lib/audit/sanitize.test.ts b/packages/vitnode/src/lib/audit/sanitize.test.ts new file mode 100644 index 0000000000..59239bb9c1 --- /dev/null +++ b/packages/vitnode/src/lib/audit/sanitize.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from "vitest"; + +import { + AUDIT_ARRAY_MAX_ITEMS, + AUDIT_CHANGES_MAX_FIELDS, + AUDIT_MAX_DEPTH, + AUDIT_OBJECT_MAX_KEYS, + AUDIT_REDACTED, + AUDIT_RICH_TEXT, + AUDIT_STRING_MAX_LENGTH, + AUDIT_TRUNCATED, +} from "./const"; +import { + isSensitiveAuditKey, + sanitizeAuditChanges, + sanitizeAuditMetadata, + sanitizeAuditValue, +} from "./sanitize"; + +const nested = (depth: number): unknown => + depth === 0 ? "leaf" : { child: nested(depth - 1) }; + +describe("isSensitiveAuditKey", () => { + it.each([ + ["accessToken", true], + ["client_secret", true], + ["passwordHash", true], + ["apiKey", true], + ["recoveryCodes", true], + ["REFRESH-TOKEN", true], + ["sessionId", true], + ["Authorization", true], + ["maxTokens", false], + ["keyboard", false], + ["name", false], + ["sessionCount", false], + ["apiVersion", false], + ])("%s -> %s", (key, sensitive) => { + expect(isSensitiveAuditKey(key)).toBe(sensitive); + }); +}); + +describe("sanitizeAuditValue", () => { + it("cuts a long string", () => { + const value = sanitizeAuditValue("x".repeat(AUDIT_STRING_MAX_LENGTH + 10)); + + expect(value).toBe(`${"x".repeat(AUDIT_STRING_MAX_LENGTH)}…`); + }); + + it("summarises a value nested past the depth limit", () => { + let value = sanitizeAuditValue(nested(AUDIT_MAX_DEPTH + 2)); + for (let depth = 0; depth < AUDIT_MAX_DEPTH; depth++) { + value = (value as { child: unknown }).child; + } + + expect(value).toBe(AUDIT_TRUNCATED); + }); + + it("keeps only an error's name", () => { + const error = new TypeError("postgres://user:hunter2@db/prod"); + + expect(sanitizeAuditValue(error)).toEqual({ error: "TypeError" }); + }); + + it("stores a rich-text document as a marker", () => { + expect( + sanitizeAuditValue({ content: [{ type: "paragraph" }], type: "doc" }), + ).toBe(AUDIT_RICH_TEXT); + }); + + it("drops class instances, functions and symbols", () => { + class Connection { + password = "hunter2"; + } + + expect( + sanitizeAuditValue({ + connection: new Connection(), + fn: () => 1, + kept: 1, + symbol: Symbol("x"), + }), + ).toEqual({ kept: 1 }); + }); + + it("redacts sensitive keys at any depth", () => { + expect( + sanitizeAuditValue({ + name: "Ann", + provider: { clientSecret: "abc", id: "github" }, + }), + ).toEqual({ + name: "Ann", + provider: { clientSecret: AUDIT_REDACTED, id: "github" }, + }); + }); + + it.each([ + [Number.NaN, null], + [Number.POSITIVE_INFINITY, null], + [10n, "10"], + [new Date("2026-01-02T03:04:05.000Z"), "2026-01-02T03:04:05.000Z"], + [new Date("not a date"), null], + ])("turns %s into %s", (input, output) => { + expect(sanitizeAuditValue(input)).toEqual(output); + }); + + it("cuts long arrays and wide objects with a marker", () => { + const array = sanitizeAuditValue( + Array.from({ length: AUDIT_ARRAY_MAX_ITEMS + 5 }, (_, index) => index), + ) as unknown[]; + const object = sanitizeAuditValue( + Object.fromEntries( + Array.from({ length: AUDIT_OBJECT_MAX_KEYS + 5 }, (_, index) => [ + `k${index}`, + index, + ]), + ), + ) as Record; + + expect(array).toHaveLength(AUDIT_ARRAY_MAX_ITEMS + 1); + expect(array.at(-1)).toBe(AUDIT_TRUNCATED); + expect(Object.keys(object)).toHaveLength(AUDIT_OBJECT_MAX_KEYS + 1); + expect(object[AUDIT_TRUNCATED]).toBe(true); + }); +}); + +describe("sanitizeAuditMetadata", () => { + it("replaces metadata over the size budget with a marker", () => { + const metadata = Object.fromEntries( + Array.from({ length: 40 }, (_, index) => [ + `field${index}`, + "x".repeat(AUDIT_STRING_MAX_LENGTH), + ]), + ); + + expect(sanitizeAuditMetadata(metadata)).toEqual({ + [AUDIT_TRUNCATED]: true, + }); + }); + + it.each([null, undefined])("is empty for %s", metadata => { + expect(sanitizeAuditMetadata(metadata)).toEqual({}); + }); +}); + +describe("sanitizeAuditChanges", () => { + it("keeps only the fact that a sensitive or flagged field changed", () => { + expect( + sanitizeAuditChanges({ + name: { from: "a", to: "b" }, + password: { from: "old", to: "new" }, + webhook: { from: "x", redacted: true, to: "y" }, + }), + ).toEqual({ + name: { from: "a", to: "b" }, + password: { redacted: true }, + webhook: { redacted: true }, + }); + }); + + it("keeps at most the field limit", () => { + const changes = Object.fromEntries( + Array.from({ length: AUDIT_CHANGES_MAX_FIELDS + 5 }, (_, index) => [ + `f${index}`, + { from: index, to: index + 1 }, + ]), + ); + + expect(Object.keys(sanitizeAuditChanges(changes) ?? {})).toHaveLength( + AUDIT_CHANGES_MAX_FIELDS, + ); + }); + + it("drops the values but keeps the field names over the size budget", () => { + const long = "x".repeat(AUDIT_STRING_MAX_LENGTH); + const changes = Object.fromEntries( + Array.from({ length: 20 }, (_, index) => [ + `f${index}`, + { from: long, to: long }, + ]), + ); + + const result = sanitizeAuditChanges({ + ...changes, + token: { from: "a", to: "b" }, + }); + + expect(result?.f0).toEqual({ from: AUDIT_TRUNCATED, to: AUDIT_TRUNCATED }); + expect(result?.token).toEqual({ redacted: true }); + expect(Object.keys(result ?? {})).toHaveLength(21); + }); + + it.each([null, undefined, {}])("is null for %s", changes => { + expect(sanitizeAuditChanges(changes)).toBeNull(); + }); +}); diff --git a/packages/vitnode/src/lib/audit/sanitize.ts b/packages/vitnode/src/lib/audit/sanitize.ts new file mode 100644 index 0000000000..b0aadb153b --- /dev/null +++ b/packages/vitnode/src/lib/audit/sanitize.ts @@ -0,0 +1,209 @@ +import type { AuditChange, AuditChanges } from "./const"; + +import { + AUDIT_ARRAY_MAX_ITEMS, + AUDIT_CHANGES_MAX_FIELDS, + AUDIT_JSON_MAX_BYTES, + AUDIT_MAX_DEPTH, + AUDIT_OBJECT_MAX_KEYS, + AUDIT_REDACTED, + AUDIT_RICH_TEXT, + AUDIT_STRING_MAX_LENGTH, + AUDIT_TRUNCATED, +} from "./const"; + +/** Words that make a key sensitive wherever they appear in it. */ +const SENSITIVE_WORDS = new Set([ + "authorization", + "cookie", + "cookies", + "credential", + "credentials", + "hash", + "otp", + "passphrase", + "passwd", + "password", + "salt", + "secret", + "secrets", + "totp", +]); + +/** Word pairs that are sensitive together but harmless apart (`api` + `key`). */ +const SENSITIVE_PAIRS: readonly (readonly [string, string])[] = [ + ["access", "key"], + ["api", "key"], + ["private", "key"], + ["recovery", "code"], + ["recovery", "codes"], + ["session", "id"], + ["signing", "key"], +]; + +/** Splits `refreshToken`, `refresh_token` and `REFRESH-TOKEN` into lowercase words. */ +const keyWords = (key: string): string[] => + key + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(word => word !== ""); + +/** + * Whether a field or metadata key names something that must never be stored + * in an audit entry: passwords and their hashes, tokens, secrets, API and + * private keys, cookies, recovery codes, credentials. Matched on whole words, + * so `accessToken` and `client_secret` are sensitive while `maxTokens` and + * `keyboard` are not. + */ +export const isSensitiveAuditKey = (key: string): boolean => { + const words = keyWords(key); + if (words.some(word => SENSITIVE_WORDS.has(word))) return true; + // `token` names a credential; `tokens` is a count (`maxTokens`). + if (words.at(-1) === "token") return true; + + return SENSITIVE_PAIRS.some(([first, second]) => { + const index = words.indexOf(first); + + return index !== -1 && words[index + 1] === second; + }); +}; + +const isRichTextDocument = (value: object): boolean => + (value as { type?: unknown }).type === "doc" && + Array.isArray((value as { content?: unknown }).content); + +const truncateString = (value: string): string => + value.length > AUDIT_STRING_MAX_LENGTH + ? `${value.slice(0, AUDIT_STRING_MAX_LENGTH)}…` + : value; + +const isPlainObject = (value: object): value is Record => { + const prototype = Object.getPrototypeOf(value) as unknown; + + return prototype === Object.prototype || prototype === null; +}; + +/** + * Turns any value into a bounded, JSON-safe one that is safe to store: + * sensitive keys are replaced with `[redacted]`, long strings and collections + * are cut, rich-text documents become a marker (the revision keeps the + * document), errors keep their name only, and anything that is not data + * (functions, symbols, class instances) is dropped. Universal. + */ +export const sanitizeAuditValue = (value: unknown, depth = 0): unknown => { + if (value === null) return null; + + switch (typeof value) { + case "bigint": + return value.toString(); + case "boolean": + return value; + case "number": + return Number.isFinite(value) ? value : null; + case "object": + break; + case "string": + return truncateString(value); + default: + return undefined; + } + + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? null : value.toISOString(); + } + // An error's message is where connection strings and tokens end up, so only + // its class survives. + if (value instanceof Error) return { error: value.name }; + if (depth >= AUDIT_MAX_DEPTH) return AUDIT_TRUNCATED; + + if (Array.isArray(value)) { + const items = value + .slice(0, AUDIT_ARRAY_MAX_ITEMS) + .map(item => sanitizeAuditValue(item, depth + 1) ?? null); + + return value.length > AUDIT_ARRAY_MAX_ITEMS + ? [...items, AUDIT_TRUNCATED] + : items; + } + + if (isRichTextDocument(value)) return AUDIT_RICH_TEXT; + if (!isPlainObject(value)) return undefined; + + const result: Record = {}; + const entries = Object.entries(value); + for (const [key, entry] of entries.slice(0, AUDIT_OBJECT_MAX_KEYS)) { + if (isSensitiveAuditKey(key)) { + result[key] = AUDIT_REDACTED; + continue; + } + const sanitized = sanitizeAuditValue(entry, depth + 1); + if (sanitized !== undefined) result[key] = sanitized; + } + if (entries.length > AUDIT_OBJECT_MAX_KEYS) result[AUDIT_TRUNCATED] = true; + + return result; +}; + +const byteLength = (value: unknown): number => + new TextEncoder().encode(JSON.stringify(value)).byteLength; + +/** + * Sanitizes free-form metadata with {@link sanitizeAuditValue} and enforces + * the serialized size budget: metadata over it is replaced with + * `{ "[truncated]": true }` rather than half-stored. + */ +export const sanitizeAuditMetadata = ( + metadata: null | Record | undefined, +): Record => { + if (!metadata) return {}; + const sanitized = sanitizeAuditValue(metadata); + if (!sanitized || typeof sanitized !== "object" || Array.isArray(sanitized)) { + return {}; + } + if (byteLength(sanitized) > AUDIT_JSON_MAX_BYTES) { + return { [AUDIT_TRUNCATED]: true }; + } + + return sanitized as Record; +}; + +/** + * Sanitizes structured changes: a sensitive field keeps only the fact that it + * changed (`{ redacted: true }`), every value goes through + * {@link sanitizeAuditValue}, at most {@link AUDIT_CHANGES_MAX_FIELDS} fields + * are kept, and when the result is still over the size budget the values are + * dropped while the field names stay. `null` when nothing is left. + */ +export const sanitizeAuditChanges = ( + changes: AuditChanges | null | undefined, +): AuditChanges | null => { + if (!changes) return null; + + const result: AuditChanges = {}; + for (const [field, change] of Object.entries(changes).slice( + 0, + AUDIT_CHANGES_MAX_FIELDS, + )) { + if (isSensitiveAuditKey(field) || change.redacted) { + result[field] = { redacted: true }; + continue; + } + + const entry: AuditChange = {}; + if ("from" in change) entry.from = sanitizeAuditValue(change.from) ?? null; + if ("to" in change) entry.to = sanitizeAuditValue(change.to) ?? null; + result[field] = entry; + } + + if (Object.keys(result).length === 0) return null; + if (byteLength(result) <= AUDIT_JSON_MAX_BYTES) return result; + + return Object.fromEntries( + Object.entries(result).map(([field, change]) => [ + field, + change.redacted ? change : { from: AUDIT_TRUNCATED, to: AUDIT_TRUNCATED }, + ]), + ); +}; diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 0faad31d27..c0849b5ab2 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -46,6 +46,11 @@ "@vitnode/core:cron": "Cron Jobs", "@vitnode/core:cron:can_view": "View cron jobs", "@vitnode/core:cron:can_run": "Run cron jobs", + "@vitnode/core:activity_logs": "Activity Logs", + "@vitnode/core:activity_logs:can_view": "View activity logs", + "@vitnode/core:activity_logs:can_view_sensitive": "View IP addresses and request ids", + "@vitnode/core:activity_logs:can_export": "Export activity logs", + "@vitnode/core:activity_logs:can_manage_retention": "Change how long activity logs are kept", "@vitnode/core:report_reasons": "Report reasons", "@vitnode/core:report_reasons:can_view": "View report reasons", "@vitnode/core:report_reasons:can_create": "Create report reasons", @@ -1695,7 +1700,8 @@ "delete": "Delete", "duplicate": "Duplicate", "hide": "Hide", - "unhide": "Unhide" + "unhide": "Unhide", + "activity": "Activity" }, "create": { "title": "Create {name}", @@ -2785,6 +2791,194 @@ } } } + }, + "activity_logs": { + "columns": { + "date": "Date", + "actor": "Actor", + "area": "Area", + "plugin": "Plugin", + "action": "Action", + "target": "Target", + "details": "Details" + }, + "areas": { + "admin": "AdminCP", + "moderator": "ModeratorCP", + "public": "Site", + "system": "System" + }, + "actor_types": { + "staff": "Staff", + "api": "Member", + "system": "System" + }, + "system_actor": "System", + "content_noun": "content", + "deleted_actor": "{name} (deleted account)", + "removed_plugin": "{plugin} (removed)", + "open_details": "Details of: {action}", + "filters": { + "label": "Filter activity", + "search": "Search", + "search_placeholder": "Search targets, actions and people", + "plugin": "Plugin", + "area": "Area", + "action": "Action", + "from": "From", + "to": "To", + "all": "All", + "actor": "Actor: {name}", + "clear_actor": "Show everyone", + "reset": "Clear filters" + }, + "load_more": "Load older activity", + "loading": "Loading activity", + "empty_title": "No activity yet", + "empty_desc": "Nothing matches these filters. Try a wider date range, or clear the filters.", + "error_title": "Activity could not be loaded", + "error_desc": "Something went wrong reading the log. Refresh the page to try again.", + "detail": { + "title": "Activity details", + "loading": "Loading details", + "actor": "Actor", + "date": "Date", + "area": "Area", + "plugin": "Plugin", + "action": "Action", + "target": "Target", + "scope": "Scope", + "changes": "Changes", + "no_changes": "No field values are recorded for this operation.", + "before": "Before", + "after": "After", + "empty_value": "Empty", + "redacted": "Changed - the value is never recorded", + "revision": "Revision", + "revision_version": "Version {version}", + "revision_unavailable": "The revision this entry points at is no longer available - it was pruned, or you cannot view this content type.", + "metadata": "Details", + "technical": "Technical", + "ip_address": "IP address", + "correlation_id": "Request id", + "open_target": "Open", + "filter_actor": "Show this person's activity", + "not_found": "This entry does not exist, or you cannot see it." + }, + "history": { + "title": "Activity", + "desc": "Everything recorded about this item, newest first.", + "empty": "Nothing has been recorded for this item yet.", + "load_more": "Load older activity" + }, + "actions": { + "content": { + "created": "Created {type}", + "updated": "Updated {type}", + "deleted": "Deleted {type}", + "duplicated": "Duplicated {type}", + "published": "Published {type}", + "unpublished": "Unpublished {type}", + "hidden": "Hid {type}", + "unhidden": "Unhid {type}", + "restored": "Restored a revision of {type}", + "scheduled": "Scheduled {type}", + "schedule_cancelled": "Cancelled a schedule of {type}", + "translation_created": "Added a translation of {type}", + "translation_updated": "Updated a translation of {type}", + "translation_deleted": "Deleted a translation of {type}", + "translation_published": "Published a translation of {type}", + "translation_unpublished": "Unpublished a translation of {type}", + "translation_restored": "Restored a translation of {type}" + }, + "role": { + "created": "Created role", + "updated": "Updated role", + "deleted": "Deleted role" + }, + "admin_permissions": { + "created": "Added administrator permissions", + "updated": "Changed administrator permissions", + "deleted": "Removed administrator permissions" + }, + "moderator_permissions": { + "created": "Added moderator permissions", + "updated": "Changed moderator permissions", + "deleted": "Removed moderator permissions" + }, + "user": { + "updated": "Updated user", + "roles_changed": "Changed user roles", + "password_reset": "Reset password", + "email_verified": "Verified email address", + "device_revoked": "Signed out a device", + "devices_revoked": "Signed out all devices" + }, + "settings": { + "updated": "Updated settings" + }, + "audit_settings": { + "updated": "Changed log retention" + }, + "audit_logs": { + "pruned": "Deleted expired log entries", + "exported": "Exported the activity log" + }, + "ai_access": { + "role_updated": "Changed AI access of a role", + "user_override_saved": "Set an AI exception", + "user_override_removed": "Removed an AI exception" + }, + "notifications": { + "type_updated": "Changed a notification type", + "paused": "Paused notifications", + "resumed": "Resumed notifications", + "emails_cancelled": "Cancelled queued notification emails", + "all_marked_read": "Marked every notification read", + "all_deleted": "Deleted every notification", + "member_preferences_reset": "Reset members' notification preferences" + }, + "report_reason": { + "created": "Created report reason", + "updated": "Updated report reason", + "deleted": "Deleted report reason", + "reordered": "Reordered report reasons" + }, + "report": { + "claimed": "Took report", + "assigned": "Assigned report", + "unassigned": "Unassigned report", + "note_added": "Added a note to a report", + "note_edited": "Edited a report note", + "note_deleted": "Deleted a report note", + "resolved": "Resolved report", + "reopened": "Reopened report", + "action_taken": "Acted on reported content" + }, + "submission": { + "approved": "Approved a submission", + "rejected": "Rejected a submission" + }, + "cron": { + "run": "Ran a cron job" + } + }, + "fields": { + "status": "Status", + "assigneeId": "Assignee", + "resolution": "Resolution", + "name": "Name", + "color": "Color", + "prefix": "Prefix", + "roleId": "Primary role", + "secondaryRoleIds": "Secondary roles", + "unrestricted": "Unrestricted", + "retentionDays": "Retention (days)", + "email": "Email", + "password": "Password", + "requiresDetails": "Requires details", + "title": "Title" + } } }, "admin": { @@ -3611,6 +3805,31 @@ "title": "No administrators found", "description": "Assign a role or user to grant administrator permissions." } + }, + "logs": { + "open": "Logs", + "title": { + "admin": "Admin logs", + "moderator": "Moderator logs" + }, + "desc": { + "admin": "Who changed what in the AdminCP, plus what plugins, members and scheduled jobs did - newest first.", + "moderator": "What moderators did: reports, approvals and content they hid or restored." + }, + "export": "Export CSV", + "export_error": "The export could not be downloaded. Try again in a moment.", + "settings": { + "title": "Retention", + "open": "Retention settings", + "desc": "One period for both staff logs. Entries older than this are deleted by a daily job. Shorter keeps less personal data around; longer keeps a longer trail.", + "retention": "Keep entries for", + "forever": "Forever", + "days": "{count, plural, one {# day} other {# days}}", + "saved": "Retention saved", + "saved_desc": "Entries older than {period} will be deleted by the next daily run.", + "saved_forever": "Entries will be kept until you change this.", + "error": "The retention period could not be saved." + } } }, "moderation": { diff --git a/packages/vitnode/src/pages/admin/staff/admins/logs.tsx b/packages/vitnode/src/pages/admin/staff/admins/logs.tsx new file mode 100644 index 0000000000..4d173e40da --- /dev/null +++ b/packages/vitnode/src/pages/admin/staff/admins/logs.tsx @@ -0,0 +1,39 @@ +import type { PluginRoutePageProps } from "@/routing"; +import type { AdminStaffLogsRouteData } from "@/tanstack/admin/staff/logs-route"; +import type { ActivityRouteSearch } from "@/views/activity-logs/activity-search"; + +import { AdminStaffLogsBreadcrumbContent } from "@/tanstack/admin/staff/breadcrumbs"; +import { loadAdminStaffLogsRoute } from "@/tanstack/admin/staff/logs-route"; +import { AdminStaffLogsRouteContent } from "@/tanstack/admin/staff/logs-screen"; +import { + defineAdminRoute, + routeBreadcrumbGroup, +} from "@/tanstack/plugin-routes"; + +const AdminsLogsPage = ({ + loaderData, + navigate, + search, +}: PluginRoutePageProps) => ( + +); + +export const route = defineAdminRoute< + AdminStaffLogsRouteData, + ActivityRouteSearch +>({ + // `head` after `load`, always. + load: async ({ context, search, t }) => + await loadAdminStaffLogsRoute({ ...context, search, t, type: "admin" }), + head: ({ loaderData }) => ({ ...loaderData }), + + breadcrumb: routeBreadcrumbGroup(function AdminsLogsBreadcrumb() { + return ; + }), +}); + +export default AdminsLogsPage; diff --git a/packages/vitnode/src/pages/admin/staff/moderators/logs.tsx b/packages/vitnode/src/pages/admin/staff/moderators/logs.tsx new file mode 100644 index 0000000000..79f953bd5a --- /dev/null +++ b/packages/vitnode/src/pages/admin/staff/moderators/logs.tsx @@ -0,0 +1,39 @@ +import type { PluginRoutePageProps } from "@/routing"; +import type { AdminStaffLogsRouteData } from "@/tanstack/admin/staff/logs-route"; +import type { ActivityRouteSearch } from "@/views/activity-logs/activity-search"; + +import { AdminStaffLogsBreadcrumbContent } from "@/tanstack/admin/staff/breadcrumbs"; +import { loadAdminStaffLogsRoute } from "@/tanstack/admin/staff/logs-route"; +import { AdminStaffLogsRouteContent } from "@/tanstack/admin/staff/logs-screen"; +import { + defineAdminRoute, + routeBreadcrumbGroup, +} from "@/tanstack/plugin-routes"; + +const ModeratorsLogsPage = ({ + loaderData, + navigate, + search, +}: PluginRoutePageProps) => ( + +); + +export const route = defineAdminRoute< + AdminStaffLogsRouteData, + ActivityRouteSearch +>({ + // `head` after `load`, always. + load: async ({ context, search, t }) => + await loadAdminStaffLogsRoute({ ...context, search, t, type: "moderator" }), + head: ({ loaderData }) => ({ ...loaderData }), + + breadcrumb: routeBreadcrumbGroup(function ModeratorsLogsBreadcrumb() { + return ; + }), +}); + +export default ModeratorsLogsPage; diff --git a/packages/vitnode/src/routes.tsx b/packages/vitnode/src/routes.tsx index 6a74580d85..9c56c1f056 100644 --- a/packages/vitnode/src/routes.tsx +++ b/packages/vitnode/src/routes.tsx @@ -35,6 +35,7 @@ import { ADMIN_SIGN_IN_NAMESPACES } from "./tanstack/admin/sign-in-route"; import { normalizeAdminSignInSearch } from "./tanstack/admin/sign-in-search"; import { ADMIN_STAFF_CREATE_NAMESPACES } from "./tanstack/admin/staff/create-route"; import { ADMIN_STAFF_EDIT_NAMESPACES } from "./tanstack/admin/staff/edit-route"; +import { ADMIN_STAFF_LOGS_NAMESPACES } from "./tanstack/admin/staff/logs-route"; import { ADMIN_STAFF_NAMESPACES } from "./tanstack/admin/staff/route"; import { normalizeStaffRouteSearch } from "./tanstack/admin/staff/route-search"; import { ADMIN_USER_NAMESPACES } from "./tanstack/admin/users/detail-route"; @@ -84,6 +85,7 @@ import { import { normalizeSearchRouteSearch } from "./tanstack/search/route-search"; import { SETTINGS_NAMESPACES } from "./tanstack/settings/route"; import { normalizeSsoSettingsSearch } from "./tanstack/sso-connections/route-search"; +import { normalizeActivitySearch } from "./views/activity-logs/activity-search"; /** * Every URL `@vitnode/core` owns, as the same kind of declaration a plugin @@ -414,6 +416,14 @@ export const routes = defineRoutes([ pendingComponent: FormPendingSkeleton, }), + page("/admin/core/staff/admins/logs", { + area: "admin", + component: lazy(() => import("./pages/admin/staff/admins/logs")), + messages: ADMIN_STAFF_LOGS_NAMESPACES, + pendingComponent: TablePendingSkeleton, + search: normalizeActivitySearch, + }), + page("/admin/core/staff/moderators", { area: "admin", component: lazy(() => import("./pages/admin/staff/moderators/index")), @@ -436,6 +446,14 @@ export const routes = defineRoutes([ pendingComponent: FormPendingSkeleton, }), + page("/admin/core/staff/moderators/logs", { + area: "admin", + component: lazy(() => import("./pages/admin/staff/moderators/logs")), + messages: ADMIN_STAFF_LOGS_NAMESPACES, + pendingComponent: TablePendingSkeleton, + search: normalizeActivitySearch, + }), + page("/admin/content/*", { area: "admin", component: lazy(() => import("./pages/admin/content")), diff --git a/packages/vitnode/src/tanstack/admin/content/editorial/panels.tsx b/packages/vitnode/src/tanstack/admin/content/editorial/panels.tsx index 21811bcd67..d6d6e98ed6 100644 --- a/packages/vitnode/src/tanstack/admin/content/editorial/panels.tsx +++ b/packages/vitnode/src/tanstack/admin/content/editorial/panels.tsx @@ -1,5 +1,6 @@ import React from "react"; +import { ActivityContentPanel } from "@/views/admin/views/content/actions/activity-action"; import { DeliveryContentPanel } from "@/views/admin/views/content/actions/delivery-action"; import { contentDeliveryRequestLocale } from "@/views/admin/views/content/actions/delivery-model"; import { HistoryContentPanel } from "@/views/admin/views/content/actions/history-action"; @@ -8,6 +9,7 @@ import { ScheduleContentPanel } from "@/views/admin/views/content/actions/schedu import type { ContentRowPanelProps } from "../slots"; +import { RouteMessages } from "../../../i18n/route-messages"; import { useContentTypeForm } from "../form/spec"; import { ContentEditorialHost } from "./host"; @@ -112,7 +114,39 @@ const DeliveryPanel = ({ ); +/** + * The record's audit trail - for every content type, editorial or not. Its own + * messages, because the content screen does not load the Activity Logs ones. + */ +const ActivityPanel = ({ + entry, + finalFocus, + itemId, + onOpenChange, + open, + singular, +}: ContentRowPanelProps) => ( + + + +); + export const contentEditorialRowPanels = { + activity: ActivityPanel, delivery: DeliveryPanel, history: HistoryPanel, preview: PreviewPanel, diff --git a/packages/vitnode/src/tanstack/admin/content/row-actions.tsx b/packages/vitnode/src/tanstack/admin/content/row-actions.tsx index 6b2c1c3b20..dbe637c591 100644 --- a/packages/vitnode/src/tanstack/admin/content/row-actions.tsx +++ b/packages/vitnode/src/tanstack/admin/content/row-actions.tsx @@ -1,6 +1,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import { + ActivityIcon, CalendarClockIcon, CopyPlusIcon, EllipsisIcon, @@ -298,6 +299,7 @@ const ACTION_ICONS: Record< Exclude, React.ReactNode > = { + activity: , delete: , delivery: , duplicate: , diff --git a/packages/vitnode/src/tanstack/admin/staff/breadcrumbs.tsx b/packages/vitnode/src/tanstack/admin/staff/breadcrumbs.tsx index 5b61cf3f92..d7896bfa7c 100644 --- a/packages/vitnode/src/tanstack/admin/staff/breadcrumbs.tsx +++ b/packages/vitnode/src/tanstack/admin/staff/breadcrumbs.tsx @@ -64,6 +64,19 @@ const StaffEditCrumb = ({ type }: StaffBreadcrumbProps) => { ); }; +const StaffLogsCrumb = ({ type }: StaffBreadcrumbProps) => { + const labels = useStaffLabels(type); + const t = useTranslations("admin.staff.logs"); + + return ( + + ); +}; + export const AdminStaffBreadcrumbContent = (props: StaffBreadcrumbProps) => ( @@ -85,3 +98,11 @@ export const AdminStaffEditBreadcrumbContent = ( ); + +export const AdminStaffLogsBreadcrumbContent = ( + props: StaffBreadcrumbProps, +) => ( + + + +); diff --git a/packages/vitnode/src/tanstack/admin/staff/index.ts b/packages/vitnode/src/tanstack/admin/staff/index.ts index a27d890ccd..95bfb120f9 100644 --- a/packages/vitnode/src/tanstack/admin/staff/index.ts +++ b/packages/vitnode/src/tanstack/admin/staff/index.ts @@ -3,6 +3,7 @@ export { AdminStaffBreadcrumbContent, AdminStaffCreateBreadcrumbContent, AdminStaffEditBreadcrumbContent, + AdminStaffLogsBreadcrumbContent, } from "./breadcrumbs"; export type { AdminStaffCreateRouteData } from "./create-route"; @@ -23,6 +24,13 @@ export { } from "./edit-route"; export type { AdminStaffEditRouteProps } from "./edit-screen"; export { AdminStaffEditRouteContent } from "./edit-screen"; +export type { AdminStaffLogsRouteData } from "./logs-route"; +export { + ADMIN_STAFF_LOGS_NAMESPACES, + loadAdminStaffLogsRoute, +} from "./logs-route"; +export type { AdminStaffLogsRouteProps } from "./logs-screen"; +export { AdminStaffLogsRouteContent } from "./logs-screen"; export { adminStaffCatalogQuery, adminStaffEntryQuery, @@ -59,6 +67,7 @@ export { staffCreateHref, staffEditHref, staffListHref, + staffLogsHref, staffPermissionsForSubmit, staffTypeFromSegment, } from "@/views/admin/views/core/staff/staff-model"; diff --git a/packages/vitnode/src/tanstack/admin/staff/logs-route.tsx b/packages/vitnode/src/tanstack/admin/staff/logs-route.tsx new file mode 100644 index 0000000000..6e33b84858 --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/staff/logs-route.tsx @@ -0,0 +1,71 @@ +import type { PermissionStaffType } from "@/api/lib/permission-staff"; +import type { PluginRouteTranslator } from "@/routing"; +import type { ActivityRouteSearch } from "@/views/activity-logs/activity-search"; + +import { + activityFilterOptionsQueryOptions, + activityListQueryOptions, +} from "@/views/activity-logs/activity-query"; +import { activityFiltersOf } from "@/views/activity-logs/activity-search"; + +import type { AdminScreenContext } from "../screen"; + +import { requireAdminPermission } from "../screen"; + +/** `/admin/core/staff/{admins,moderators}/logs`, as everything a route needs and nothing it owns. */ + +export const ADMIN_STAFF_LOGS_NAMESPACES = [ + "admin.staff", + "core.activity_logs", + "core.global", +] as const; + +export interface AdminStaffLogsRouteData { + description: string; + title: string; + type: PermissionStaffType; +} + +export const ACTIVITY_LOGS_MODULE = "activity_logs"; + +export const loadAdminStaffLogsRoute = async ({ + adminAccess, + queryClient, + search, + t, + type, +}: AdminScreenContext & { + search: ActivityRouteSearch; + t: PluginRouteTranslator; + type: PermissionStaffType; +}): Promise => { + requireAdminPermission(adminAccess, { + module: ACTIVITY_LOGS_MODULE, + permission: "can_view", + }); + + // The filter options decide which plugin messages the screen loads, so they + // are awaited; the first page warms alongside and the screen keeps its + // skeleton local if it is not there yet. + await Promise.all([ + queryClient.query({ + ...activityFilterOptionsQueryOptions(), + staleTime: "static", + }), + queryClient + .infiniteQuery({ + ...activityListQueryOptions({ + ...activityFiltersOf(search), + staff: type, + }), + staleTime: "static", + }) + .catch(() => undefined), + ]); + + return { + description: t(`admin.staff.logs.desc.${type}`), + title: t(`admin.staff.logs.title.${type}`), + type, + }; +}; diff --git a/packages/vitnode/src/tanstack/admin/staff/logs-screen.tsx b/packages/vitnode/src/tanstack/admin/staff/logs-screen.tsx new file mode 100644 index 0000000000..6cc93ae537 --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/staff/logs-screen.tsx @@ -0,0 +1,174 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; +import { DownloadIcon, Settings2Icon } from "lucide-react"; +import React from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import type { PermissionStaffType } from "@/api/lib/permission-staff"; +import type { ActivityRouteSearch } from "@/views/activity-logs/activity-search"; + +import { Button } from "@/components/ui/button"; +import { PageTitle } from "@/components/ui/page-title"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { Spinner } from "@/components/ui/spinner"; +import { manifest } from "@/manifest"; +import { activityPluginNamespaces } from "@/views/activity-logs/activity-labels"; +import { + activityFilterOptionsQueryOptions, + downloadActivityExport, +} from "@/views/activity-logs/activity-query"; +import { ActivityScreen } from "@/views/activity-logs/activity-screen"; +import { + activityFiltersOf, + activitySearchOf, +} from "@/views/activity-logs/activity-search"; + +import type { AdminTableNavigate } from "../table-search"; +import type { AdminStaffLogsRouteData } from "./logs-route"; + +import { RouteMessages } from "../../i18n/route-messages"; +import { useAdminPermission } from "../permissions"; +import { + ACTIVITY_LOGS_MODULE, + ADMIN_STAFF_LOGS_NAMESPACES, +} from "./logs-route"; + +const ActivityRetentionForm = React.lazy( + async () => await import("@/views/activity-logs/activity-retention-form"), +); + +const permission = (name: string) => ({ + module: ACTIVITY_LOGS_MODULE, + permission: name, + plugin: manifest.id, +}); + +const ExportButton = ({ + search, + type, +}: { + search: ActivityRouteSearch; + type: PermissionStaffType; +}) => { + const t = useTranslations("admin.staff.logs"); + const tError = useTranslations("core.global.errors"); + const [isExporting, setIsExporting] = React.useState(false); + + return ( + + ); +}; + +const RetentionSheet = ({ canManage }: { canManage: boolean }) => { + const t = useTranslations("admin.staff.logs.settings"); + const [isOpen, setIsOpen] = React.useState(false); + + return ( + + }> + + {t("open")} + + + + {t("title")} + + {t("desc")} + + + + + + } + > + { + setIsOpen(false); + }} + /> + + + + ); +}; + +export interface AdminStaffLogsRouteProps extends AdminStaffLogsRouteData { + navigate: AdminTableNavigate; + search: ActivityRouteSearch; +} + +/** AdminCP -> Staff -> Administrators / Moderators -> Logs. */ +export const AdminStaffLogsRouteContent = ({ + description, + navigate, + search, + title, + type, +}: AdminStaffLogsRouteProps) => { + const { data: options } = useSuspenseQuery( + activityFilterOptionsQueryOptions(), + ); + const canExport = useAdminPermission(permission("can_export")); + const canManageRetention = useAdminPermission( + permission("can_manage_retention"), + ); + + return ( + +

+ + + {canExport ? : null} + + + } + entryId={search.log ?? null} + filters={activityFiltersOf(search)} + onNavigate={next => { + void navigate({ + resetScroll: false, + search: activitySearchOf(next), + }); + }} + staff={type} + /> +
+ + ); +}; diff --git a/packages/vitnode/src/tanstack/admin/staff/screen.tsx b/packages/vitnode/src/tanstack/admin/staff/screen.tsx index cc0497675a..476803c672 100644 --- a/packages/vitnode/src/tanstack/admin/staff/screen.tsx +++ b/packages/vitnode/src/tanstack/admin/staff/screen.tsx @@ -1,7 +1,8 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; -import { PlusIcon } from "lucide-react"; +import { HistoryIcon, PlusIcon } from "lucide-react"; import React from "react"; +import { useTranslations } from "use-intl"; import type { DataTableNavigation } from "@/components/table/navigation"; @@ -9,8 +10,12 @@ import { AdminStaffPermissionGate } from "@/components/staff-permission/provider import { DataTableNavigationProvider } from "@/components/table/navigation"; import { buttonVariants } from "@/components/ui/button"; import { PageTitle } from "@/components/ui/page-title"; +import { manifest } from "@/manifest"; import { adminStaffPermissions } from "@/views/admin/views/core/shared/admin-permissions"; -import { staffCreateHref } from "@/views/admin/views/core/staff/staff-model"; +import { + staffCreateHref, + staffLogsHref, +} from "@/views/admin/views/core/staff/staff-model"; import { StaffTableContent } from "@/views/admin/views/core/staff/table/staff-table-content"; import type { AdminTableNavigate } from "../table-search"; @@ -18,6 +23,7 @@ import type { AdminStaffRouteData } from "./route"; import type { StaffRouteSearch, UncheckedStaffSearch } from "./route-search"; import { RouteMessages } from "../../i18n/route-messages"; +import { ACTIVITY_LOGS_MODULE } from "./logs-route"; import { adminStaffQuery, useStaffDeleteCallback } from "./query"; import { ADMIN_STAFF_NAMESPACES } from "./route"; import { staffSearchFrom, staffSearchParams } from "./route-search"; @@ -27,6 +33,26 @@ export interface AdminStaffRouteProps extends AdminStaffRouteData { search: UncheckedStaffSearch; } +const LogsLink = ({ type }: { type: AdminStaffRouteData["type"] }) => { + const t = useTranslations("admin.staff.logs"); + + return ( + + + + {t("open")} + + + ); +}; + export const AdminStaffRouteContent = ({ adminUserId, createLabel, @@ -59,6 +85,7 @@ export const AdminStaffRouteContent = ({
+ diff --git a/packages/vitnode/src/tests/audit-recorder.ts b/packages/vitnode/src/tests/audit-recorder.ts new file mode 100644 index 0000000000..f26f756a83 --- /dev/null +++ b/packages/vitnode/src/tests/audit-recorder.ts @@ -0,0 +1,31 @@ +import type { Table } from "drizzle-orm"; + +import { getTableName } from "drizzle-orm"; + +/** + * For statement-recording database mocks: answers inserts into + * `core_audit_logs` on its own, so the audit entry every audited write adds + * neither consumes the scripted results nor shows up among the recorded + * statements - and can be asserted on separately through `entries`. + */ +export const createAuditRecorder = () => { + const entries: Record[] = []; + + return { + entries, + /** The audit insert builder, or `null` for any other table. */ + insert: (table: unknown) => { + if (getTableName(table as Table) !== "core_audit_logs") return null; + + return { + values: (value: Record) => { + entries.push(value); + + return { + returning: async () => Promise.resolve([{ id: entries.length }]), + }; + }, + }; + }, + }; +}; diff --git a/packages/vitnode/src/tests/revisions-table.ts b/packages/vitnode/src/tests/revisions-table.ts index 6d18e08db2..336e0b40a6 100644 --- a/packages/vitnode/src/tests/revisions-table.ts +++ b/packages/vitnode/src/tests/revisions-table.ts @@ -4,6 +4,8 @@ import { PgDialect } from "drizzle-orm/pg-core"; import type { ContentTranslationRevisionSnapshot } from "@/content/revisions"; +import { createAuditRecorder } from "./audit-recorder"; + const dialect = new PgDialect(); const boundValue = (condition: SQL | undefined, column: string): unknown => { @@ -76,18 +78,21 @@ export const createTestRevisionsTable = () => { return query; }; + const audit = createAuditRecorder(); + const db = { - insert: () => ({ - values: (values: RevisionWrite) => ({ - returning: async () => { - nextId += 1; - written.push(values); - rows.push({ ...values, id: nextId }); - - return await Promise.resolve([{ id: nextId }]); - }, - }), - }), + insert: (table: unknown) => + audit.insert(table) ?? { + values: (values: RevisionWrite) => ({ + returning: async () => { + nextId += 1; + written.push(values); + rows.push({ ...values, id: nextId }); + + return await Promise.resolve([{ id: nextId }]); + }, + }), + }, select, transaction: async (body: (tx: unknown) => Promise) => await body(db), }; @@ -110,5 +115,5 @@ export const createTestRevisionsTable = () => { }); }; - return { db, seed, written }; + return { audits: audit.entries, db, seed, written }; }; diff --git a/packages/vitnode/src/views/activity-logs/activity-changes.tsx b/packages/vitnode/src/views/activity-logs/activity-changes.tsx new file mode 100644 index 0000000000..3a93f6d1e6 --- /dev/null +++ b/packages/vitnode/src/views/activity-logs/activity-changes.tsx @@ -0,0 +1,106 @@ +import { LockIcon } from "lucide-react"; +import { useTranslations } from "use-intl"; + +import type { ActivityDetail } from "./activity-query"; + +const formatValue = (value: unknown): string => { + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + + return JSON.stringify(value); +}; + +const isEmpty = (value: unknown): boolean => + value === null || + value === undefined || + value === "" || + (Array.isArray(value) && value.length === 0); + +const ChangeValue = ({ value }: { value: unknown }) => { + const t = useTranslations("core.activity_logs.detail"); + + if (isEmpty(value)) { + return ( + {t("empty_value")} + ); + } + + return ( + + {formatValue(value)} + + ); +}; + +export interface ActivityChangeRow { + after?: unknown; + before?: unknown; + label: string; + name: string; + redacted?: boolean; +} + +/** Recorded changes as rows of field, before and after, rather than raw JSON. */ +export const activityChangeRows = ( + changes: ActivityDetail["changes"], + label: (name: string) => string, +): ActivityChangeRow[] => + Object.entries(changes ?? {}).map(([name, change]) => ({ + after: change.to, + before: change.from, + label: label(name), + name, + redacted: change.redacted === true, + })); + +/** + * Before/after pairs, one field per row. Stacked on a phone, side by side + * from `sm` up. A redacted field says that it changed and nothing more. + */ +export const ActivityChanges = ({ rows }: { rows: ActivityChangeRow[] }) => { + const t = useTranslations("core.activity_logs.detail"); + + if (rows.length === 0) { + return ( +

+ {t("no_changes")} +

+ ); + } + + return ( +
+ {rows.map(row => ( +
+
{row.label}
+ {row.redacted ? ( +
+ + {t("redacted")} +
+ ) : ( +
+
+ + {t("before")} + + +
+
+ + {t("after")} + + +
+
+ )} +
+ ))} +
+ ); +}; diff --git a/packages/vitnode/src/views/activity-logs/activity-detail-pending.tsx b/packages/vitnode/src/views/activity-logs/activity-detail-pending.tsx new file mode 100644 index 0000000000..7fc6e4b52c --- /dev/null +++ b/packages/vitnode/src/views/activity-logs/activity-detail-pending.tsx @@ -0,0 +1,25 @@ +import { useTranslations } from "use-intl"; + +import { Skeleton } from "@/components/ui/skeleton"; + +/** Shaped like the facts and the first change it stands in for. */ +export const ActivityDetailPending = () => { + const t = useTranslations("core.activity_logs.detail"); + + return ( +
+ + {t("loading")} + +
+ {["actor", "date", "area", "plugin"].map(key => ( +
+ + +
+ ))} +
+ +
+ ); +}; diff --git a/packages/vitnode/src/views/activity-logs/activity-detail.tsx b/packages/vitnode/src/views/activity-logs/activity-detail.tsx new file mode 100644 index 0000000000..5db84a678a --- /dev/null +++ b/packages/vitnode/src/views/activity-logs/activity-detail.tsx @@ -0,0 +1,221 @@ +import { useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { ExternalLinkIcon, FilterIcon } from "lucide-react"; +import { useTranslations } from "use-intl"; + +import { DateFormat } from "@/components/date-format"; +import { Badge } from "@/components/ui/badge"; +import { Button, buttonVariants } from "@/components/ui/button"; + +import type { ActivityFilterOptions } from "./activity-query"; + +import { activityChangeRows, ActivityChanges } from "./activity-changes"; +import { ActivityDetailPending } from "./activity-detail-pending"; +import { + ActivityActor, + ActivityAreaBadge, + useActivityLabels, +} from "./activity-labels"; +import { activityDetailQueryOptions } from "./activity-query"; + +const Section = ({ + children, + title, +}: { + children: React.ReactNode; + title: string; +}) => ( +
+

{title}

+ {children} +
+); + +const Fact = ({ + children, + label, +}: { + children: React.ReactNode; + label: string; +}) => ( +
+
+ {label} +
+
{children}
+
+); + +const metadataValue = (value: unknown): string => + typeof value === "string" ? value : JSON.stringify(value); + +/** + * The body of the detail sheet: who, when, where and what changed, with the + * content revision the entry points at when there is one. + */ +export const ActivityDetailContent = ({ + id, + onFilterActor, + plugins, +}: { + id: number; + /** Narrows the list to this entry's actor; omitted where there is no list. */ + onFilterActor?: (actor: { id: number; name: string }) => void; + plugins: ActivityFilterOptions["plugins"]; +}) => { + const t = useTranslations("core.activity_logs.detail"); + const labels = useActivityLabels(plugins); + const { data: entry, isError } = useQuery(activityDetailQueryOptions(id)); + + if (isError) { + return ( +

+ {t("not_found")} +

+ ); + } + if (!entry) return ; + + const changes = activityChangeRows(entry.changes, name => + labels.field(entry, name), + ); + const revisionRows = (entry.revision?.fields ?? []).map(field => ({ + after: field.after, + before: field.before, + label: labels.field(entry, field.name), + name: field.name, + })); + const { changedFields, locale, ...metadata } = entry.metadata as Record< + string, + unknown + > & { + changedFields?: unknown; + locale?: unknown; + }; + const changedNames = Array.isArray(changedFields) + ? changedFields.filter((name): name is string => typeof name === "string") + : []; + + return ( +
+
+ + + + + + + + + + {labels.plugin(entry.pluginId)} + + + {labels.target(entry)} + {typeof locale === "string" ? ( + + {locale} + + ) : null} + + + {entry.scope ? ( + + + {entry.scope.family}:{entry.scope.id} + + + ) : null} +
+ +
+ {entry.targetHref ? ( + + + {t("open_target")} + + ) : null} + {onFilterActor && entry.actor ? ( + + ) : null} +
+ +
+ {changedNames.length > 0 && + changes.length === 0 && + revisionRows.length === 0 ? ( +
    + {changedNames.map(name => ( +
  • + {labels.field(entry, name)} +
  • + ))} +
+ ) : ( + + )} +
+ + {entry.revision ? ( +
+ {entry.revision.available && entry.revision.version !== null ? ( +

+ {t("revision_version", { version: entry.revision.version })} +

+ ) : ( +

+ {t("revision_unavailable")} +

+ )} +
+ ) : null} + + {Object.keys(metadata).length > 0 ? ( +
+
+ {Object.entries(metadata).map(([key, value]) => ( + + + {metadataValue(value)} + + + ))} +
+
+ ) : null} + + {entry.ipAddress || entry.correlationId ? ( +
+
+ {entry.ipAddress ? ( + + {entry.ipAddress} + + ) : null} + {entry.correlationId ? ( + + + {entry.correlationId} + + + ) : null} +
+
+ ) : null} +
+ ); +}; + +export default ActivityDetailContent; diff --git a/packages/vitnode/src/views/activity-logs/activity-filters.tsx b/packages/vitnode/src/views/activity-logs/activity-filters.tsx new file mode 100644 index 0000000000..6d01a91366 --- /dev/null +++ b/packages/vitnode/src/views/activity-logs/activity-filters.tsx @@ -0,0 +1,273 @@ +import { FilterXIcon, SearchIcon, XIcon } from "lucide-react"; +import React from "react"; +import { useTranslations } from "use-intl"; + +import type { AuditStaffLog } from "@/lib/audit/const"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + NativeSelect, + NativeSelectOption, +} from "@/components/ui/native-select"; +import { AUDIT_AREAS, AUDIT_CORE_PLUGIN_ID } from "@/lib/audit/const"; + +import type { ActivityFilterOptions, ActivityFilters } from "./activity-query"; + +import { useActivityLabels } from "./activity-labels"; + +/** + * Core's report and approval actions are only ever recorded in the moderator + * area, and core's other actions never are; plugin actions can be either. + */ +const isModerationAction = (action: string) => + action.startsWith("report.") || action.startsWith("submission."); + +/** The moderator area has a log of its own. */ +const ADMIN_LOG_AREAS = AUDIT_AREAS.filter(area => area !== "moderator"); + +const FilterSelect = ({ + id, + label, + onChange, + options, + value, +}: { + id: string; + label: string; + onChange: (value: string | undefined) => void; + options: { label: string; value: string }[]; + value: string | undefined; +}) => { + const t = useTranslations("core.activity_logs.filters"); + + return ( +
+ + { + onChange(event.target.value || undefined); + }} + size="sm" + value={value ?? ""} + > + {t("all")} + {options.map(option => ( + + {option.label} + + ))} + +
+ ); +}; + +/** + * The Activity Logs filters. Every change goes straight to `onChange`, which + * the screen writes into the URL - so a filtered view can be bookmarked and + * shared, and the back button undoes a filter. + */ +export const ActivityFiltersBar = ({ + actorName, + filters, + onChange, + options, + staff, +}: { + /** The name of the actor `filters.actorId` narrows to, for its chip. */ + actorName?: null | string; + filters: ActivityFilters; + onChange: (filters: ActivityFilters) => void; + options: ActivityFilterOptions; + /** The staff log being filtered - it decides which areas and actions apply. */ + staff: AuditStaffLog; +}) => { + const t = useTranslations("core.activity_logs.filters"); + const tAreas = useTranslations("core.activity_logs.areas"); + const labels = useActivityLabels(options.plugins); + // The draft belongs to the search it was typed against: once the URL's + // search changes (a reset, the back button), the field shows the URL again. + const [draft, setDraft] = React.useState({ + for: filters.search, + value: filters.search ?? "", + }); + const search = + draft.for === filters.search ? draft.value : (filters.search ?? ""); + const setSearch = (value: string) => { + setDraft({ for: filters.search, value }); + }; + + const set = + (key: TKey) => + (value: ActivityFilters[TKey] | undefined) => { + onChange({ ...filters, [key]: value }); + }; + + const actions = React.useMemo(() => { + const visible = options.actions.filter( + entry => + (!filters.pluginId || entry.pluginId === filters.pluginId) && + (staff === "moderator" + ? entry.pluginId !== AUDIT_CORE_PLUGIN_ID || + isModerationAction(entry.action) + : !isModerationAction(entry.action)), + ); + const unique = new Map(); + for (const entry of visible) { + if (!unique.has(entry.action)) { + unique.set( + entry.action, + labels.action({ + action: entry.action, + contentTypeId: null, + pluginId: entry.pluginId, + }), + ); + } + } + + return [...unique] + .map(([value, label]) => ({ label, value })) + .sort((a, b) => a.label.localeCompare(b.label)); + }, [filters.pluginId, labels, options.actions, staff]); + + const hasFilters = Object.values(filters).some( + value => value !== undefined && value !== "", + ); + + return ( +
+
{ + event.preventDefault(); + set("search")(search.trim() || undefined); + }} + > + +
+ { + setSearch(event.target.value); + }} + placeholder={t("search_placeholder")} + type="search" + value={search} + /> + +
+
+ +
+ { + onChange({ ...filters, action: undefined, pluginId: value }); + }} + options={options.plugins.map(plugin => ({ + label: labels.plugin(plugin.id), + value: plugin.id, + }))} + value={filters.pluginId} + /> + {staff === "admin" ? ( + { + set("area")(ADMIN_LOG_AREAS.find(area => area === value)); + }} + options={ADMIN_LOG_AREAS.map(area => ({ + label: tAreas(area), + value: area, + }))} + value={filters.area} + /> + ) : null} + +
+ + { + set("from")(event.target.value || undefined); + }} + type="date" + value={filters.from ?? ""} + /> +
+
+ + { + set("to")(event.target.value || undefined); + }} + type="date" + value={filters.to ?? ""} + /> +
+ {hasFilters ? ( + + ) : null} +
+ + {filters.actorId ? ( +
+ + {t("actor", { name: actorName ?? `#${filters.actorId}` })} + + +
+ ) : null} +
+ ); +}; diff --git a/packages/vitnode/src/views/activity-logs/activity-history.tsx b/packages/vitnode/src/views/activity-logs/activity-history.tsx new file mode 100644 index 0000000000..7ffeefc158 --- /dev/null +++ b/packages/vitnode/src/views/activity-logs/activity-history.tsx @@ -0,0 +1,181 @@ +import { useInfiniteQuery } from "@tanstack/react-query"; +import { HistoryIcon } from "lucide-react"; +import React from "react"; +import { useTranslations } from "use-intl"; + +import { DateFormat } from "@/components/date-format"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; + +import { activityChangeRows, ActivityChanges } from "./activity-changes"; +import { + ActivityActor, + ActivityAreaBadge, + useActivityLabels, +} from "./activity-labels"; +import { activityEntityQueryOptions } from "./activity-query"; + +export interface ActivityHistoryProps { + /** The plugin the resource belongs to - for a content record, its content type's plugin. */ + pluginId: string; + /** The resource's id, as recorded (`String(id)`). */ + targetId: number | string; + /** The recorded target type - a content type id such as `blog.category` for content. */ + targetType: string; +} + +const HistoryPending = () => { + const t = useTranslations("core.activity_logs"); + + return ( +
+ + {t("loading")} + + {Array.from({ length: 4 }, (_, index) => ( +
+ +
+ + +
+
+ ))} +
+ ); +}; + +/** + * The activity history of one resource, newest first - who created, changed, + * published or moderated it, and what moved. Reads the central Activity Logs + * through `GET /admin/activity-logs/entity`, which an admin may call with the + * Activity Logs permission or with `can_view` on the content type itself. + * AdminCP only. + * + * @example + * ```tsx + * import { ActivityHistory } from "@vitnode/core/views/activity-logs/activity-history"; + * + * ; + * ``` + */ +export const ActivityHistory = ({ + pluginId, + targetId, + targetType, +}: ActivityHistoryProps) => { + const t = useTranslations("core.activity_logs"); + const labels = useActivityLabels(); + const query = useInfiniteQuery( + activityEntityQueryOptions({ + pluginId, + targetId: String(targetId), + targetType, + }), + ); + const entries = React.useMemo( + () => query.data?.pages.flatMap(page => page.edges) ?? [], + [query.data], + ); + + if (query.isPending) return ; + if (query.isError) { + return ( +

+ {t("error_desc")} +

+ ); + } + if (entries.length === 0) { + return ( +
+ + + +

+ {t("history.empty")} +

+
+ ); + } + + return ( +
+
    + {entries.map(entry => { + const rows = activityChangeRows(entry.changes, name => + labels.field(entry, name), + ); + const changedFields = Array.isArray(entry.metadata.changedFields) + ? entry.metadata.changedFields.filter( + (name): name is string => typeof name === "string", + ) + : []; + const locale = + typeof entry.metadata.locale === "string" + ? entry.metadata.locale + : null; + + return ( +
  1. + + + +
    +
    + + {labels.action(entry)} + + {locale ? {locale} : null} + +
    +
    + + · + +
    + {rows.length > 0 ? ( +
    + + {t("detail.changes")} + +
    + +
    +
    + ) : null} + {rows.length === 0 && changedFields.length > 0 ? ( +
      + {changedFields.map(name => ( +
    • + + {labels.field(entry, name)} + +
    • + ))} +
    + ) : null} +
    +
  2. + ); + })} +
+ {query.hasNextPage ? ( + + ) : null} +
+ ); +}; diff --git a/packages/vitnode/src/views/activity-logs/activity-labels.tsx b/packages/vitnode/src/views/activity-logs/activity-labels.tsx new file mode 100644 index 0000000000..6394591f9b --- /dev/null +++ b/packages/vitnode/src/views/activity-logs/activity-labels.tsx @@ -0,0 +1,221 @@ +import { Link } from "@tanstack/react-router"; +import { cn } from "cn"; +import React from "react"; +import { useTranslations } from "use-intl"; + +import type { AuditArea } from "@/lib/audit/const"; + +import { Badge } from "@/components/ui/badge"; +import { UserFormat } from "@/components/user-format"; +import { + contentEntityKey, + contentTypeName, + humanizeFieldName, +} from "@/content/admin/labels"; +import { AUDIT_CORE_PLUGIN_ID } from "@/lib/audit/const"; + +import type { ActivityEntry, ActivityFilterOptions } from "./activity-query"; + +/** The namespaces every Activity Logs screen reads. */ +export const ACTIVITY_NAMESPACES = [ + "core.activity_logs", + "core.global", +] as const; + +/** + * The plugin message subtrees an entry's label may come from: a plugin's own + * `audit` actions, and the `content` nouns and field names of its content + * types. Loaded with the screen, so a label never renders as a key. + */ +export const activityPluginNamespaces = ( + plugins: readonly { id: string }[], +): string[] => + plugins + .filter(plugin => plugin.id !== AUDIT_CORE_PLUGIN_ID) + .flatMap(plugin => [`${plugin.id}.audit`, `${plugin.id}.content`]); + +/** A translator over any key, for keys only known at runtime. */ +interface LooseTranslator { + (key: string, values?: Record): string; + has: (key: string) => boolean; +} + +/** `topic.moved` -> "Topic moved": what an action reads as with no translation. */ +export const humanizeAuditAction = (action: string): string => + humanizeFieldName(action.replaceAll(".", " ").replaceAll("_", " ")); + +/** + * Turns stored identifiers into words, in the reader's language - and never + * runs a plugin's code to do it, so an entry of a removed plugin still reads: + * + * - Content Engine actions use core's sentence with the content type's noun + * from the plugin's messages (`Updated Category`). + * - Core actions use core's own messages. + * - A plugin's action uses `{pluginId}.audit.actions.{action}`, when the plugin + * ships one. + * - Anything else is the action id made readable. + */ +export const useActivityLabels = ( + plugins: ActivityFilterOptions["plugins"] = [], +) => { + const t = useTranslations("core.activity_logs"); + const tAll = useTranslations() as unknown as LooseTranslator; + const titles = React.useMemo( + () => new Map(plugins.map(plugin => [plugin.id, plugin.title])), + [plugins], + ); + + return React.useMemo(() => { + const has = (key: string) => { + try { + return tAll.has(key); + } catch { + return false; + } + }; + + const contentNoun = (pluginId: string, contentTypeId: null | string) => { + // A filter option names the action, not a record of one type. + if (!contentTypeId) return t("content_noun"); + const key = `${pluginId}.content.${contentEntityKey(contentTypeId)}.label`; + + return has(key) + ? tAll(key, { count: 1 }) + : contentTypeName(contentTypeId); + }; + + const action = ( + entry: Pick, + ): string => { + const coreKey = `core.activity_logs.actions.${entry.action}`; + if (entry.action.startsWith("content.") && has(coreKey)) { + return tAll(coreKey, { + type: contentNoun(entry.pluginId, entry.contentTypeId), + }); + } + if (entry.pluginId === AUDIT_CORE_PLUGIN_ID && has(coreKey)) { + return tAll(coreKey); + } + const pluginKey = `${entry.pluginId}.audit.actions.${entry.action}`; + if (has(pluginKey)) return tAll(pluginKey); + + return humanizeAuditAction(entry.action); + }; + + const field = ( + entry: Pick, + name: string, + ): string => { + if (entry.contentTypeId) { + const key = `${entry.pluginId}.content.${contentEntityKey(entry.contentTypeId)}.fields.${name}`; + if (has(key)) return tAll(key); + } + const coreKey = `core.activity_logs.fields.${name}`; + if (has(coreKey)) return tAll(coreKey); + + return humanizeFieldName(name); + }; + + const plugin = (pluginId: string): string => { + const title = titles.get(pluginId); + if (title) return title; + if (pluginId === AUDIT_CORE_PLUGIN_ID) return "VitNode"; + + return t("removed_plugin", { plugin: pluginId }); + }; + + const area = (value: AuditArea): string => t(`areas.${value}`); + + const target = ( + entry: Pick, + ): string => { + if (entry.target.label) return entry.target.label; + const noun = + entry.contentTypeId && entry.target.type === entry.contentTypeId + ? contentNoun(entry.pluginId, entry.contentTypeId) + : humanizeFieldName(entry.target.type); + + return entry.target.id ? `${noun} #${entry.target.id}` : noun; + }; + + return { action, area, field, plugin, target }; + }, [t, tAll, titles]); +}; + +const AREA_VARIANTS = { + admin: "default", + moderator: "warning", + public: "secondary", + system: "outline", +} as const satisfies Record; + +export const ActivityAreaBadge = ({ area }: { area: AuditArea }) => { + const t = useTranslations("core.activity_logs.areas"); + + return ( + + {t(area)} + + ); +}; + +/** + * The actor of an entry: the account as it is today, linked to its AdminCP + * page for an admin; the name the entry was written under once the account is + * gone; "System" for background work. + */ +export const ActivityActor = ({ + className, + entry, + linkToAdmin = false, +}: { + className?: string; + entry: Pick; + linkToAdmin?: boolean; +}) => { + const t = useTranslations("core.activity_logs"); + + if (entry.actorType === "system") { + return ( + + {t("system_actor")} + + ); + } + + if (!entry.actor) { + return ( + + {entry.actorName + ? t("deleted_actor", { name: entry.actorName }) + : t("system_actor")} + + ); + } + + const name = ( + + ); + + if (!linkToAdmin) { + return {name}; + } + + return ( + + {name} + + ); +}; diff --git a/packages/vitnode/src/views/activity-logs/activity-query.ts b/packages/vitnode/src/views/activity-logs/activity-query.ts new file mode 100644 index 0000000000..5229414f4e --- /dev/null +++ b/packages/vitnode/src/views/activity-logs/activity-query.ts @@ -0,0 +1,242 @@ +import { + infiniteQueryOptions, + keepPreviousData, + queryOptions, +} from "@tanstack/react-query"; + +import type { AuditArea, AuditStaffLog } from "@/lib/audit/const"; + +import { fetcherClient } from "@/lib/fetcher-client"; +import { OPERATIONAL_STALE_TIME } from "@/lib/query-freshness"; +import { manifest } from "@/manifest"; +import { fetcher } from "@/tanstack/fetcher"; + +/** The filters an Activity Logs list is read with, as they sit in the URL. */ +export interface ActivityFilters { + action?: string; + actorId?: number; + area?: AuditArea; + from?: string; + pluginId?: string; + search?: string; + targetId?: string; + targetType?: string; + to?: string; +} + +export class ActivityRequestError extends Error { + constructor(status: number, what: string) { + super(`Reading ${what} answered ${status}.`); + this.name = "ActivityRequestError"; + this.status = status; + } + + readonly status: number; +} + +const ACTIVITY_ROOT = ["vitnode", "activity-logs"] as const; + +/** Every cached Activity Logs read - invalidate it after a write. */ +export const activityQueryRoot = () => ACTIVITY_ROOT; + +/** The filters of one staff log, as the API reads them. */ +export type ActivityStaffFilters = ActivityFilters & { staff: AuditStaffLog }; + +const fetchPage = async (filters: ActivityStaffFilters, cursor?: string) => { + const response = await fetcher({ + plugin: manifest.id, + args: { query: { ...filters, ...(cursor ? { cursor } : {}) } }, + method: "get", + module: "admin/activity-logs", + path: "/", + }); + if (!response.ok) { + throw new ActivityRequestError(response.status, "the activity log"); + } + + return await response.json(); +}; + +export type ActivityPage = Awaited>; + +export type ActivityEntry = ActivityPage["edges"][number]; + +/** + * The list, page by page: a keyset cursor newest-first, so "Load more" is as + * cheap on the hundredth page as on the first. Keyed on the filters, and keeps + * the previous result on screen while a new filter loads. + */ +export const activityListQueryOptions = (filters: ActivityStaffFilters) => + infiniteQueryOptions({ + getNextPageParam: (last: ActivityPage) => + last.pageInfo.hasNextPage + ? (last.pageInfo.endCursor ?? undefined) + : undefined, + initialPageParam: undefined as string | undefined, + placeholderData: keepPreviousData, + queryFn: async ({ pageParam }) => await fetchPage(filters, pageParam), + queryKey: [...activityQueryRoot(), "list", filters], + retry: false, + staleTime: OPERATIONAL_STALE_TIME, + }); + +const fetchDetail = async (id: number) => { + const response = await fetcher({ + plugin: manifest.id, + args: { params: { id } }, + method: "get", + module: "admin/activity-logs", + path: "/{id}", + }); + if (!response.ok) { + throw new ActivityRequestError(response.status, "an activity entry"); + } + + return await response.json(); +}; + +export type ActivityDetail = Awaited>; + +export const activityDetailQueryOptions = (id: number) => + queryOptions({ + queryFn: async () => await fetchDetail(id), + queryKey: [...activityQueryRoot(), "detail", id], + retry: false, + staleTime: OPERATIONAL_STALE_TIME, + }); + +const fetchFilterOptions = async () => { + const response = await fetcher({ + plugin: manifest.id, + method: "get", + module: "admin/activity-logs", + path: "/filters", + }); + if (!response.ok) { + throw new ActivityRequestError(response.status, "activity filters"); + } + + return await response.json(); +}; + +export type ActivityFilterOptions = Awaited< + ReturnType +>; + +/** The plugins and actions the filters offer. Changes only with a deploy. */ +export const activityFilterOptionsQueryOptions = () => + queryOptions({ + queryFn: fetchFilterOptions, + queryKey: [...activityQueryRoot(), "filters"], + retry: false, + staleTime: Infinity, + }); + +const fetchEntityPage = async ( + target: { pluginId: string; targetId: string; targetType: string }, + cursor?: string, +) => { + const response = await fetcher({ + plugin: manifest.id, + args: { query: { ...target, ...(cursor ? { cursor } : {}) } }, + method: "get", + module: "admin/activity-logs", + path: "/entity", + }); + if (!response.ok) { + throw new ActivityRequestError(response.status, "an entity's activity"); + } + + return await response.json(); +}; + +export type ActivityEntityPage = Awaited>; + +export type ActivityEntityEntry = ActivityEntityPage["edges"][number]; + +/** One resource's history, newest first - see `ActivityHistory`. */ +export const activityEntityQueryOptions = (target: { + pluginId: string; + targetId: string; + targetType: string; +}) => + infiniteQueryOptions({ + getNextPageParam: (last: ActivityEntityPage) => + last.pageInfo.hasNextPage + ? (last.pageInfo.endCursor ?? undefined) + : undefined, + initialPageParam: undefined as string | undefined, + queryFn: async ({ pageParam }) => await fetchEntityPage(target, pageParam), + queryKey: [...activityQueryRoot(), "entity", target], + retry: false, + staleTime: OPERATIONAL_STALE_TIME, + }); + +const fetchSettings = async () => { + const response = await fetcher({ + plugin: manifest.id, + method: "get", + module: "admin/activity-logs", + path: "/settings", + }); + if (!response.ok) { + throw new ActivityRequestError(response.status, "activity settings"); + } + + return await response.json(); +}; + +export const activitySettingsQueryOptions = () => + queryOptions({ + queryFn: fetchSettings, + queryKey: [...activityQueryRoot(), "settings"], + retry: false, + staleTime: OPERATIONAL_STALE_TIME, + }); + +/** Saves the retention period; the caller invalidates and toasts. */ +export const saveActivitySettings = async (retentionDays: number) => { + const response = await fetcher({ + plugin: manifest.id, + args: { body: { retentionDays } }, + method: "put", + module: "admin/activity-logs", + path: "/settings", + }); + if (!response.ok) { + throw new ActivityRequestError(response.status, "activity settings"); + } + + return await response.json(); +}; + +/** + * Downloads the CSV export of the current filters - the existing download + * pattern: fetched with the AdminCP session, saved through an object URL. The + * API checks `can_export` and leaves IP addresses out without + * `can_view_sensitive`. Browser-only. + */ +export const downloadActivityExport = async ( + filters: ActivityStaffFilters, +): Promise => { + const response = await fetcherClient({ + plugin: manifest.id, + args: { query: filters }, + method: "get", + module: "admin/activity-logs", + options: { credentials: "include" }, + path: "/export", + }); + if (!response.ok) { + throw new ActivityRequestError(response.status, "the activity export"); + } + + const objectUrl = URL.createObjectURL(await response.blob()); + const anchor = document.createElement("a"); + anchor.href = objectUrl; + anchor.download = `${filters.staff}-logs-${new Date().toISOString().slice(0, 10)}.csv`; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(objectUrl); +}; diff --git a/packages/vitnode/src/views/activity-logs/activity-retention-form.tsx b/packages/vitnode/src/views/activity-logs/activity-retention-form.tsx new file mode 100644 index 0000000000..e96c0f21c7 --- /dev/null +++ b/packages/vitnode/src/views/activity-logs/activity-retention-form.tsx @@ -0,0 +1,107 @@ +import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; +import { z } from "zod"; + +import type { AutoFormOnSubmit } from "@/components/form/auto-form"; + +import { AutoForm } from "@/components/form/auto-form"; +import { AutoFormSheetFooter } from "@/components/form/auto-form-sheet-footer"; +import { AutoFormSelect } from "@/components/form/fields/select"; +import { AUDIT_RETENTION_DAYS_OPTIONS } from "@/lib/audit/const"; + +import { + activityQueryRoot, + activitySettingsQueryOptions, + saveActivitySettings, +} from "./activity-query"; + +const OPTIONS = AUDIT_RETENTION_DAYS_OPTIONS.map(String) as [ + string, + ...string[], +]; + +/** + * The retention form, in the settings sheet. Saving refreshes the cached + * settings, toasts what will happen next and closes the sheet. + */ +export const ActivityRetentionForm = ({ + canManage, + onSaved, +}: { + canManage: boolean; + onSaved: () => void; +}) => { + const t = useTranslations("admin.staff.logs.settings"); + const tGlobal = useTranslations("core.global"); + const tError = useTranslations("core.global.errors"); + const queryClient = useQueryClient(); + const { data } = useSuspenseQuery(activitySettingsQueryOptions()); + + const period = (days: number) => + days === 0 ? t("forever") : t("days", { count: days }); + + const formSchema = z.object({ + retentionDays: z.enum(OPTIONS).default(String(data.retentionDays)), + }); + + const onSubmit: AutoFormOnSubmit = async values => { + const retentionDays = Number(values.retentionDays); + try { + await saveActivitySettings(retentionDays); + } catch { + toast.error(tError("title"), { description: t("error") }); + + return; + } + + await queryClient.invalidateQueries({ + queryKey: activityQueryRoot(), + }); + toast.success(t("saved"), { + description: + retentionDays === 0 + ? t("saved_forever") + : t("saved_desc", { period: period(retentionDays) }), + }); + onSaved(); + }; + + return ( + ( + ({ + label: period(Number(value)), + value, + }))} + /> + ), + id: "retentionDays", + }, + ]} + formSchema={formSchema} + layout={rendered => ( + <> +
+ {rendered.retentionDays} +
+ + + )} + onSubmit={onSubmit} + /> + ); +}; + +export default ActivityRetentionForm; diff --git a/packages/vitnode/src/views/activity-logs/activity-screen.tsx b/packages/vitnode/src/views/activity-logs/activity-screen.tsx new file mode 100644 index 0000000000..8d54f640c1 --- /dev/null +++ b/packages/vitnode/src/views/activity-logs/activity-screen.tsx @@ -0,0 +1,152 @@ +import { useInfiniteQuery, useSuspenseQuery } from "@tanstack/react-query"; +import { CircleAlertIcon } from "lucide-react"; +import React from "react"; +import { useTranslations } from "use-intl"; + +import type { AuditStaffLog } from "@/lib/audit/const"; + +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; +import { Skeleton } from "@/components/ui/skeleton"; + +import type { ActivityEntry, ActivityFilters } from "./activity-query"; + +import { ActivityFiltersBar } from "./activity-filters"; +import { + activityFilterOptionsQueryOptions, + activityListQueryOptions, +} from "./activity-query"; +import { ActivitySheet } from "./activity-sheet"; +import { ActivityTable } from "./activity-table"; + +/** Rows shaped like the table's, for the first load only - a refetch keeps the data. */ +export const ActivityListPending = () => { + const t = useTranslations("core.activity_logs"); + + return ( +
+ + {t("loading")} + + {Array.from({ length: 8 }, (_, index) => ( + + ))} +
+ ); +}; + +export interface ActivityScreenProps { + /** Toolbar actions beside the filters - export and retention. */ + actions?: React.ReactNode; + /** The entry whose sheet is open, from the URL. */ + entryId: null | number; + filters: ActivityFilters; + /** Writes filters and the open entry back into the URL. */ + onNavigate: (next: { + entryId: null | number; + filters: ActivityFilters; + }) => void; + /** Which staff log: admins' (with system and member activity) or moderators'. */ + staff: AuditStaffLog; +} + +/** + * Filters, the list and the detail sheet of one staff log - the admins' or the + * moderators' Activity Logs in AdminCP -> Staff. + */ +export const ActivityScreen = ({ + actions, + entryId, + filters, + onNavigate, + staff, +}: ActivityScreenProps) => { + const t = useTranslations("core.activity_logs"); + const { data: options } = useSuspenseQuery( + activityFilterOptionsQueryOptions(), + ); + const list = useInfiniteQuery( + activityListQueryOptions({ ...filters, staff }), + ); + const entries = React.useMemo( + () => list.data?.pages.flatMap(page => page.edges) ?? [], + [list.data], + ); + const [openedFrom, setOpenedFrom] = React.useState(); + const [actorName, setActorName] = React.useState(null); + + const actorFromList = filters.actorId + ? entries.find(entry => entry.actor?.id === filters.actorId)?.actor?.name + : undefined; + + return ( +
+
+
+ { + onNavigate({ entryId: null, filters: next }); + }} + options={options} + staff={staff} + /> +
+ {actions ? ( +
{actions}
+ ) : null} +
+ + {list.isPending ? : null} + {list.isError && !list.data ? ( + + + + + + {t("error_title")} + {t("error_desc")} + + + ) : null} + {list.data ? ( + { + void list.fetchNextPage(); + }} + hasNextPage={list.hasNextPage} + isFetching={list.isFetching} + isFetchingNextPage={list.isFetchingNextPage} + onOpen={entry => { + setOpenedFrom(entry); + onNavigate({ entryId: entry.id, filters }); + }} + plugins={options.plugins} + /> + ) : null} + + { + setActorName(actor.name); + onNavigate({ + entryId: null, + filters: { ...filters, actorId: actor.id }, + }); + }} + onOpenChange={open => { + if (!open) onNavigate({ entryId: null, filters }); + }} + plugins={options.plugins} + /> +
+ ); +}; diff --git a/packages/vitnode/src/views/activity-logs/activity-search.test.ts b/packages/vitnode/src/views/activity-logs/activity-search.test.ts new file mode 100644 index 0000000000..e93661826e --- /dev/null +++ b/packages/vitnode/src/views/activity-logs/activity-search.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeActivitySearch } from "./activity-search"; + +describe("normalizeActivitySearch", () => { + it("keeps every recognised filter", () => { + const search = { + action: "role.updated", + actorId: 7, + area: "admin", + from: "2026-01-01", + log: "12", + pluginId: "@vitnode/blog", + search: "News", + targetId: "42", + targetType: "blog.category", + to: "2026-01-31", + }; + + expect(normalizeActivitySearch(search)).toEqual({ + ...search, + log: 12, + }); + }); + + it.each([ + ["action", "Role Updated"], + ["actorId", "-1"], + ["actorId", "1.5"], + ["area", "superadmin"], + ["from", "01/01/2026"], + ["log", "0"], + ["pluginId", "