From 74ca140789c961e4a1aedb0aad3c4602c1cfb832 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 19:29:42 +0000 Subject: [PATCH 1/5] feat(audit): add core audit log table, service and automatic recording Adds core_audit_logs with a sanitizing, transaction-aware writer (c.get("audit").record), an auditDiff helper and trusted context frames for background work. Content Engine writes are audited inside their transactions (revision captures for editorial types, the plain services otherwise), and roles, staff permissions, users, AI and notification settings, report reasons and the moderation workflow record their own entries atomically with their changes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PSeRx3Xr1PGRJ5q7wvs7jK --- .../migration.sql | 31 + .../snapshot.json | 20720 ++++++++++++++++ .../vitnode/src/api/lib/ai/settings-store.ts | 2 +- packages/vitnode/src/api/lib/audit/context.ts | 118 + packages/vitnode/src/api/lib/audit/labels.ts | 102 + packages/vitnode/src/api/lib/audit/query.ts | 393 + .../vitnode/src/api/lib/audit/registry.ts | 44 + packages/vitnode/src/api/lib/audit/write.ts | 274 + packages/vitnode/src/api/lib/plugin.ts | 11 + .../src/api/middlewares/global.middleware.ts | 14 + packages/vitnode/src/api/models/audit.ts | 130 + .../src/api/models/notifications/admin.ts | 7 + .../admin/advanced/cron/routes/run.route.ts | 28 +- .../modules/admin/ai/routes/access.route.ts | 72 +- .../admin/ai/routes/update-settings.route.ts | 30 +- .../notifications/routes/danger.route.ts | 47 +- .../routes/report-reasons.routes.ts | 89 +- .../admin/roles/routes/create.route.ts | 70 +- .../admin/roles/routes/delete.route.ts | 17 + .../admin/roles/routes/update.route.ts | 30 +- .../admin/staff/routes/create.route.ts | 32 +- .../admin/staff/routes/delete.route.ts | 26 +- .../staff/routes/update-permissions.route.ts | 48 +- .../modules/admin/users/lib/audit-changes.ts | 52 + .../admin/users/routes/password.route.ts | 24 +- .../admin/users/routes/revoke-device.route.ts | 6 + .../users/routes/revoke-devices.route.ts | 5 + .../admin/users/routes/update.route.ts | 106 +- .../admin/users/routes/verify-email.route.ts | 40 +- .../helpers/execute-content-schedule.ts | 29 +- .../src/api/modules/cron/routes/cron.route.ts | 13 +- .../src/api/modules/moderation/lib/audit.ts | 91 + .../modules/moderation/routes/notes.routes.ts | 9 + .../moderation/routes/workflow.routes.ts | 68 +- .../queue/helpers/process-queue-tasks.ts | 14 +- packages/vitnode/src/api/plugin.ts | 7 + .../src/content/approvals/server/decide.ts | 57 +- packages/vitnode/src/content/define.ts | 44 + packages/vitnode/src/content/index.ts | 2 + packages/vitnode/src/content/server/audit.ts | 408 + .../content/server/editorial-service.test.ts | 4 +- .../src/content/server/editorial-service.ts | 180 +- .../content/server/localized-admin-routes.ts | 166 +- .../content/server/localized-service.test.ts | 4 +- .../src/content/server/schedules-model.ts | 67 +- .../src/content/server/service.test.ts | 12 +- .../vitnode/src/content/server/service.ts | 298 +- .../server/translation-editorial-service.ts | 32 +- .../src/content/server/translation-model.ts | 119 +- packages/vitnode/src/content/types.ts | 29 + packages/vitnode/src/database/audit.ts | 80 + packages/vitnode/src/database/relations.ts | 2 + packages/vitnode/src/lib/audit/actions.ts | 137 + packages/vitnode/src/lib/audit/const.ts | 101 + packages/vitnode/src/lib/audit/diff.ts | 106 + packages/vitnode/src/lib/audit/sanitize.ts | 209 + packages/vitnode/src/tests/audit-recorder.ts | 31 + packages/vitnode/src/tests/revisions-table.ts | 29 +- 58 files changed, 24537 insertions(+), 379 deletions(-) create mode 100644 apps/api/migrations/20261024192619_add_core_audit_logs/migration.sql create mode 100644 apps/api/migrations/20261024192619_add_core_audit_logs/snapshot.json create mode 100644 packages/vitnode/src/api/lib/audit/context.ts create mode 100644 packages/vitnode/src/api/lib/audit/labels.ts create mode 100644 packages/vitnode/src/api/lib/audit/query.ts create mode 100644 packages/vitnode/src/api/lib/audit/registry.ts create mode 100644 packages/vitnode/src/api/lib/audit/write.ts create mode 100644 packages/vitnode/src/api/models/audit.ts create mode 100644 packages/vitnode/src/api/modules/admin/users/lib/audit-changes.ts create mode 100644 packages/vitnode/src/api/modules/moderation/lib/audit.ts create mode 100644 packages/vitnode/src/content/server/audit.ts create mode 100644 packages/vitnode/src/database/audit.ts create mode 100644 packages/vitnode/src/lib/audit/actions.ts create mode 100644 packages/vitnode/src/lib/audit/const.ts create mode 100644 packages/vitnode/src/lib/audit/diff.ts create mode 100644 packages/vitnode/src/lib/audit/sanitize.ts create mode 100644 packages/vitnode/src/tests/audit-recorder.ts diff --git a/apps/api/migrations/20261024192619_add_core_audit_logs/migration.sql b/apps/api/migrations/20261024192619_add_core_audit_logs/migration.sql new file mode 100644 index 0000000000..ae9db06682 --- /dev/null +++ b/apps/api/migrations/20261024192619_add_core_audit_logs/migration.sql @@ -0,0 +1,31 @@ +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 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/20261024192619_add_core_audit_logs/snapshot.json b/apps/api/migrations/20261024192619_add_core_audit_logs/snapshot.json new file mode 100644 index 0000000000..9b5cae7ca2 --- /dev/null +++ b/apps/api/migrations/20261024192619_add_core_audit_logs/snapshot.json @@ -0,0 +1,20720 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "de09d6ab-1eea-4309-8a22-03a0c64938b9", + "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_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": "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_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/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/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/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..ef0d15f339 --- /dev/null +++ b/packages/vitnode/src/api/lib/audit/query.ts @@ -0,0 +1,393 @@ +import type { SQL } from "drizzle-orm"; +import type { Context } from "hono"; + +import { z } from "@hono/zod-openapi"; +import { and, desc, eq, gte, ilike, lt, 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_TARGET_ID_MAX_LENGTH, + AUDIT_TARGET_TYPE_MAX_LENGTH, +} from "@/lib/audit/const"; + +import type { AuditDatabase } from "./write"; + +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(), +}); + +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 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 or ModeratorCP 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.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 ?? "", + }, +}); + +const baseQuery = (db: AuditDatabase) => + db + .select(listSelection) + .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)) + .$dynamic(); + +/** + * 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 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 baseQuery(c.get("db")) + .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)), + 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..70b18cd19f --- /dev/null +++ b/packages/vitnode/src/api/lib/audit/registry.ts @@ -0,0 +1,44 @@ +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; +}; 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..2420adab08 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 (AdminCP + * and ModeratorCP Activity 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..a39002e643 --- /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. + */ + 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 }`). Scoped moderators only see + * entries of the scopes they moderate. + */ + 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 the AdminCP and ModeratorCP Activity 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/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.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/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.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/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/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..a75299f05f 100644 --- a/packages/vitnode/src/api/plugin.ts +++ b/packages/vitnode/src/api/plugin.ts @@ -50,6 +50,7 @@ export const newBuildPluginApiCore = buildApiPlugin({ ], permissionStaff: { moderator: { + activity_logs: ["can_view"], users: ["can_edit"], widgets: ["can_edit"], }, @@ -102,6 +103,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.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.ts b/packages/vitnode/src/content/server/audit.ts new file mode 100644 index 0000000000..8ba2d741f7 --- /dev/null +++ b/packages/vitnode/src/content/server/audit.ts @@ -0,0 +1,408 @@ +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)) + : []; + + 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: itemId, 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, + ); + const label = + held.find(entry => entry.target.label !== null)?.target.label ?? + translations.find(entry => entry.metadata.locale === defaultLocale)?.target + .label ?? + null; + const labelled = (entry: AuditPendingEntry): AuditPendingEntry => ({ + ...entry, + target: { ...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"), + ); + await writeAuditLog( + c, + labelled({ + ...created, + metadata: { ...created.metadata, 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..7d4c0d416f 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) => 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..229403bd0d --- /dev/null +++ b/packages/vitnode/src/database/audit.ts @@ -0,0 +1,80 @@ +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_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, + ), + ], +); 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.ts b/packages/vitnode/src/lib/audit/actions.ts new file mode 100644 index 0000000000..223c39e596 --- /dev/null +++ b/packages/vitnode/src/lib/audit/actions.ts @@ -0,0 +1,137 @@ +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" }, +] 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..87b10fff24 --- /dev/null +++ b/packages/vitnode/src/lib/audit/const.ts @@ -0,0 +1,101 @@ +/** + * 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]; + +/** + * 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/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.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/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 }; }; From 5d4bf935032243fbd75800c15009944f3e2b5a5b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 22:21:11 +0000 Subject: [PATCH 2/5] feat(audit): Activity Logs APIs, AdminCP and ModeratorCP screens, retention and export Adds scoped list/detail/entity/export/settings routes, moderator scope enforcement, a daily retention job, CSV export with formula escaping, the AdminCP System -> Activity Logs and ModeratorCP -> Activity screens with filters and a detail sheet, an Activity row panel on every Content Engine list, the content type audit option and developer documentation. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PSeRx3Xr1PGRJ5q7wvs7jK --- .../migration.sql | 7 + .../snapshot.json | 57 +++- .../dev/audit-logs/access-and-retention.mdx | 66 ++++ .../docs/dev/audit-logs/content-engine.mdx | 112 +++++++ .../web/content/docs/dev/audit-logs/index.mdx | 49 +++ .../web/content/docs/dev/audit-logs/meta.json | 12 + .../dev/audit-logs/record-plugin-events.mdx | 156 ++++++++++ .../content/docs/dev/audit-logs/reference.mdx | 92 ++++++ apps/web/content/docs/dev/meta.json | 1 + apps/web/src/page-cache.gen.ts | 2 + .../src/api/admin-staff-permission.test.ts | 2 + packages/vitnode/src/api/lib/audit/access.ts | 143 +++++++++ packages/vitnode/src/api/lib/audit/detail.ts | 191 ++++++++++++ packages/vitnode/src/api/lib/audit/query.ts | 84 ++++- .../vitnode/src/api/lib/audit/registry.ts | 40 +++ .../vitnode/src/api/lib/audit/retention.ts | 47 +++ .../vitnode/src/api/lib/audit/settings.ts | 34 ++ .../activity-logs.admin.module.ts | 25 ++ .../activity-logs/routes/detail.route.ts | 44 +++ .../activity-logs/routes/entity.route.ts | 81 +++++ .../activity-logs/routes/export.route.ts | 83 +++++ .../activity-logs/routes/filters.route.ts | 22 ++ .../admin/activity-logs/routes/list.route.ts | 32 ++ .../activity-logs/routes/settings.route.ts | 85 +++++ .../src/api/modules/admin/admin.module.ts | 2 + .../src/api/modules/cron/cron.module.ts | 3 +- .../modules/cron/cron/audit-retention.cron.ts | 23 ++ .../modules/moderation/moderation.module.ts | 8 + .../moderation/routes/activity.routes.ts | 102 ++++++ packages/vitnode/src/content/server/audit.ts | 31 +- packages/vitnode/src/database/audit.ts | 19 ++ packages/vitnode/src/lib/audit/actions.ts | 2 + packages/vitnode/src/lib/audit/csv.ts | 33 ++ packages/vitnode/src/locales/en.json | 221 ++++++++++++- .../src/pages/admin/system/activity-logs.tsx | 36 +++ packages/vitnode/src/pages/modcp/activity.tsx | 34 ++ packages/vitnode/src/routes.tsx | 16 + .../tanstack/admin/activity-logs/route.tsx | 66 ++++ .../tanstack/admin/activity-logs/screen.tsx | 160 ++++++++++ .../admin/content/editorial/panels.tsx | 34 ++ .../tanstack/admin/content/row-actions.tsx | 2 + .../src/tanstack/moderation/activity-route.ts | 70 +++++ .../tanstack/moderation/activity-screen.tsx | 61 ++++ .../src/tanstack/moderation/breadcrumb.tsx | 6 + .../views/activity-logs/activity-changes.tsx | 106 +++++++ .../activity-logs/activity-detail-pending.tsx | 25 ++ .../views/activity-logs/activity-detail.tsx | 225 ++++++++++++++ .../views/activity-logs/activity-filters.tsx | 264 ++++++++++++++++ .../views/activity-logs/activity-history.tsx | 181 +++++++++++ .../views/activity-logs/activity-labels.tsx | 221 +++++++++++++ .../src/views/activity-logs/activity-query.ts | 294 ++++++++++++++++++ .../activity-logs/activity-retention-form.tsx | 107 +++++++ .../views/activity-logs/activity-screen.tsx | 153 +++++++++ .../views/activity-logs/activity-search.ts | 76 +++++ .../views/activity-logs/activity-sheet.tsx | 75 +++++ .../views/activity-logs/activity-table.tsx | 189 +++++++++++ .../admin/layouts/sidebar/nav/nav-model.tsx | 5 + .../views/content/actions/activity-action.tsx | 52 ++++ .../content/actions/row-actions-model.test.ts | 4 +- .../content/actions/row-actions-model.ts | 3 + .../src/views/moderation/modcp/modcp-nav.tsx | 34 +- 61 files changed, 4385 insertions(+), 25 deletions(-) rename apps/api/migrations/{20261024192619_add_core_audit_logs => 20261024195452_add_core_audit_logs}/migration.sql (85%) rename apps/api/migrations/{20261024192619_add_core_audit_logs => 20261024195452_add_core_audit_logs}/snapshot.json (99%) create mode 100644 apps/web/content/docs/dev/audit-logs/access-and-retention.mdx create mode 100644 apps/web/content/docs/dev/audit-logs/content-engine.mdx create mode 100644 apps/web/content/docs/dev/audit-logs/index.mdx create mode 100644 apps/web/content/docs/dev/audit-logs/meta.json create mode 100644 apps/web/content/docs/dev/audit-logs/record-plugin-events.mdx create mode 100644 apps/web/content/docs/dev/audit-logs/reference.mdx create mode 100644 packages/vitnode/src/api/lib/audit/access.ts create mode 100644 packages/vitnode/src/api/lib/audit/detail.ts create mode 100644 packages/vitnode/src/api/lib/audit/retention.ts create mode 100644 packages/vitnode/src/api/lib/audit/settings.ts create mode 100644 packages/vitnode/src/api/modules/admin/activity-logs/activity-logs.admin.module.ts create mode 100644 packages/vitnode/src/api/modules/admin/activity-logs/routes/detail.route.ts create mode 100644 packages/vitnode/src/api/modules/admin/activity-logs/routes/entity.route.ts create mode 100644 packages/vitnode/src/api/modules/admin/activity-logs/routes/export.route.ts create mode 100644 packages/vitnode/src/api/modules/admin/activity-logs/routes/filters.route.ts create mode 100644 packages/vitnode/src/api/modules/admin/activity-logs/routes/list.route.ts create mode 100644 packages/vitnode/src/api/modules/admin/activity-logs/routes/settings.route.ts create mode 100644 packages/vitnode/src/api/modules/cron/cron/audit-retention.cron.ts create mode 100644 packages/vitnode/src/api/modules/moderation/routes/activity.routes.ts create mode 100644 packages/vitnode/src/lib/audit/csv.ts create mode 100644 packages/vitnode/src/pages/admin/system/activity-logs.tsx create mode 100644 packages/vitnode/src/pages/modcp/activity.tsx create mode 100644 packages/vitnode/src/tanstack/admin/activity-logs/route.tsx create mode 100644 packages/vitnode/src/tanstack/admin/activity-logs/screen.tsx create mode 100644 packages/vitnode/src/tanstack/moderation/activity-route.ts create mode 100644 packages/vitnode/src/tanstack/moderation/activity-screen.tsx create mode 100644 packages/vitnode/src/views/activity-logs/activity-changes.tsx create mode 100644 packages/vitnode/src/views/activity-logs/activity-detail-pending.tsx create mode 100644 packages/vitnode/src/views/activity-logs/activity-detail.tsx create mode 100644 packages/vitnode/src/views/activity-logs/activity-filters.tsx create mode 100644 packages/vitnode/src/views/activity-logs/activity-history.tsx create mode 100644 packages/vitnode/src/views/activity-logs/activity-labels.tsx create mode 100644 packages/vitnode/src/views/activity-logs/activity-query.ts create mode 100644 packages/vitnode/src/views/activity-logs/activity-retention-form.tsx create mode 100644 packages/vitnode/src/views/activity-logs/activity-screen.tsx create mode 100644 packages/vitnode/src/views/activity-logs/activity-search.ts create mode 100644 packages/vitnode/src/views/activity-logs/activity-sheet.tsx create mode 100644 packages/vitnode/src/views/activity-logs/activity-table.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/actions/activity-action.tsx diff --git a/apps/api/migrations/20261024192619_add_core_audit_logs/migration.sql b/apps/api/migrations/20261024195452_add_core_audit_logs/migration.sql similarity index 85% rename from apps/api/migrations/20261024192619_add_core_audit_logs/migration.sql rename to apps/api/migrations/20261024195452_add_core_audit_logs/migration.sql index ae9db06682..49ec5698d3 100644 --- a/apps/api/migrations/20261024192619_add_core_audit_logs/migration.sql +++ b/apps/api/migrations/20261024195452_add_core_audit_logs/migration.sql @@ -21,6 +21,13 @@ CREATE TABLE "core_audit_logs" ( ); --> 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 diff --git a/apps/api/migrations/20261024192619_add_core_audit_logs/snapshot.json b/apps/api/migrations/20261024195452_add_core_audit_logs/snapshot.json similarity index 99% rename from apps/api/migrations/20261024192619_add_core_audit_logs/snapshot.json rename to apps/api/migrations/20261024195452_add_core_audit_logs/snapshot.json index 9b5cae7ca2..34432ae6aa 100644 --- a/apps/api/migrations/20261024192619_add_core_audit_logs/snapshot.json +++ b/apps/api/migrations/20261024195452_add_core_audit_logs/snapshot.json @@ -1,7 +1,7 @@ { "version": "8", "dialect": "postgres", - "id": "de09d6ab-1eea-4309-8a22-03a0c64938b9", + "id": "da3c6b6c-bb28-4684-801a-35db2375a151", "prevIds": [ "0c989133-91e8-46c3-86d3-f9b7ac8ec7a3" ], @@ -90,6 +90,12 @@ "entityType": "tables", "schema": "public" }, + { + "isRlsEnabled": true, + "name": "core_audit_settings", + "entityType": "tables", + "schema": "public" + }, { "isRlsEnabled": true, "name": "core_content_containers", @@ -2768,6 +2774,45 @@ "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, @@ -19896,6 +19941,16 @@ "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" 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..4bb24c89a4 --- /dev/null +++ b/apps/web/content/docs/dev/audit-logs/access-and-retention.mdx @@ -0,0 +1,66 @@ +--- +title: Access and retention +description: Who may read the VitNode Activity Logs, how moderator scopes limit what a moderator sees, 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 a permission +of its own, a moderator only sees the moderation of what they moderate, 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 | **System → Activity 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 | +| `can_view` | Moderator | **ModeratorCP → Activity** | + +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. + +## What a moderator sees + +Moderators never see AdminCP or member activity, only entries recorded in the +moderator area - report workflow steps, approvals, and content hidden from a +report. Within those: + +- A root moderator, or one holding any moderator permission without a scope, + moderates the whole site and sees every moderation entry. +- A [scoped moderator](/docs/dev/moderation/scoped-permissions) sees the + entries of the scopes their permissions cover - a forum section grant covers + its subsections, exactly as it does for the content - plus their own. + +The rule is one SQL condition applied to the list, to the details and to an +entry id typed into the URL. An entry from another scope answers 404, so its +existence is not revealed either. + +## Retention + +Open **System → Activity Logs → Retention settings** and pick how long entries +are kept: 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/content-engine.mdx b/apps/web/content/docs/dev/audit-logs/content-engine.mdx new file mode 100644 index 0000000000..7db41131c1 --- /dev/null +++ b/apps/web/content/docs/dev/audit-logs/content-engine.mdx @@ -0,0 +1,112 @@ +--- +title: Content Engine auditing +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 +--- + +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 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/dev/audit-logs/index.mdx b/apps/web/content/docs/dev/audit-logs/index.mdx new file mode 100644 index 0000000000..6efe2303d6 --- /dev/null +++ b/apps/web/content/docs/dev/audit-logs/index.mdx @@ -0,0 +1,49 @@ +--- +title: Activity Logs +description: VitNode's audit log answers who changed what, when and on which resource - across the AdminCP, the Moderator CP, plugins and Content Engine content. +icon: History +--- + +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. Admins read it in **AdminCP → +System → Activity Logs**, moderators in **ModeratorCP → Activity**. + +## 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 auditing](/docs/dev/audit-logs/content-engine) | +| 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..4613bcaec5 --- /dev/null +++ b/apps/web/content/docs/dev/audit-logs/meta.json @@ -0,0 +1,12 @@ +{ + "title": "Activity Logs", + "description": "Who changed what, when - one audit trail for the AdminCP, moderators, plugins and content", + "icon": "History", + "pages": [ + "index", + "record-plugin-events", + "content-engine", + "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..30f5dab246 --- /dev/null +++ b/apps/web/content/docs/dev/audit-logs/record-plugin-events.mdx @@ -0,0 +1,156 @@ +--- +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. `scope` lets a moderator who only moderates that forum see the entry. + + + + +### 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 → System → Activity 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. A moderator of the +destination forum sees the same entry in **ModeratorCP → Activity**. + +## 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..6e9d42a788 --- /dev/null +++ b/apps/web/content/docs/dev/audit-logs/reference.mdx @@ -0,0 +1,92 @@ +--- +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, for scoped moderators | + +| 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 }` | +| `GET /moderation/activity`, `/activity/{id}`, `/activity/filters` | Moderator `activity_logs:can_view`, scoped | Moderator-area entries only | + +List filters: `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). 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..1167358dab 100644 --- a/apps/web/src/page-cache.gen.ts +++ b/apps/web/src/page-cache.gen.ts @@ -36,6 +36,7 @@ export const pageCacheRoutes: PageCacheRouteTable = [ { path: '/admin/core/moderation/report-reasons' }, { path: '/admin/core/staff/admins' }, { path: '/admin/core/staff/moderators' }, + { path: '/admin/core/system/activity-logs' }, { path: '/admin/core/system/files' }, { path: '/admin/core/system/integrations' }, { path: '/admin/core/system/notifications' }, @@ -64,6 +65,7 @@ export const pageCacheRoutes: PageCacheRouteTable = [ { path: '/letters/new' }, { path: '/login/reset-password' }, { path: '/login/verify-email' }, + { path: '/modcp/activity' }, { path: '/modcp/approvals' }, { path: '/modcp/reports' }, { path: '/news/new' }, 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/audit/access.ts b/packages/vitnode/src/api/lib/audit/access.ts new file mode 100644 index 0000000000..14fbf85e52 --- /dev/null +++ b/packages/vitnode/src/api/lib/audit/access.ts @@ -0,0 +1,143 @@ +import type { SQL } from "drizzle-orm"; +import type { Context } from "hono"; + +import { and, eq, inArray, or } from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; + +import type { ResolvedStaffPermissionSet } from "../permission-staff"; + +import { expandContentContainerScope } from "../../../content/containers/server/policy"; +import { core_audit_logs } from "../../../database/audit"; +import { AUDIT_CORE_PLUGIN_ID } from "../../../lib/audit/const"; +import { checkStaffPermission } from "../check-staff-permission"; +import { requireModerator } from "../moderation/access"; + +/** The staff permission module of the Activity Logs, for admins and moderators. */ +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", + }); + +/** + * Which moderation entries a moderator may read, as one SQL condition - the + * same rule for the list, the detail, and an id typed into the URL. + * + * - Only entries recorded in the moderator area, ever. + * - A root moderator, or one holding any grant without a scope, moderates the + * whole site and sees every such entry. + * - Anyone else sees the entries of the scopes their grants cover (a container + * grant covers the containers below it, exactly as it does for the content + * itself), plus their own. + * + * The scopes come from the moderator's resolved permissions, never from the + * request. + */ +export const moderatorAuditCondition = async ( + c: Context, + { set, userId }: { set: ResolvedStaffPermissionSet; userId: number }, +): Promise => { + const area = eq(core_audit_logs.area, "moderator"); + if (set.root) return area; + + const families = new Map>(); + let global = false; + for (const grant of set.permissions) { + // Reading the log is not moderating anything. + if ( + grant.plugin === AUDIT_CORE_PLUGIN_ID && + grant.module === AUDIT_PERMISSION_MODULE + ) { + continue; + } + if (!grant.scope) { + global = true; + break; + } + const current = families.get(grant.scope.family); + if (current === "all") continue; + if (grant.scope.mode === "all") { + families.set(grant.scope.family, "all"); + continue; + } + families.set( + grant.scope.family, + new Set([...(current ?? []), ...grant.scope.ids]), + ); + } + if (global) return area; + + const containerFamilies = new Set( + (c.get("core")?.contentContainers ?? []).map(entry => entry.definition.id), + ); + const scoped: SQL[] = []; + for (const [family, ids] of families) { + if (ids === "all") { + scoped.push(eq(core_audit_logs.scopeFamily, family)); + continue; + } + const covered = containerFamilies.has(family) + ? await expandContentContainerScope(c.get("db"), family, [...ids]) + : [...ids]; + if (covered === "all") { + scoped.push(eq(core_audit_logs.scopeFamily, family)); + continue; + } + if (covered.length === 0) continue; + const covering = and( + eq(core_audit_logs.scopeFamily, family), + inArray(core_audit_logs.scopeId, covered.map(String)), + ); + if (covering) scoped.push(covering); + } + + return and(area, or(eq(core_audit_logs.actorId, userId), ...scoped)) ?? area; +}; + +/** + * The signed-in moderator's access to the moderation activity log: `401` + * signed out, `403` without moderator `activity_logs:can_view` (root + * moderators hold it implicitly), and otherwise the condition every read + * applies. + */ +export const requireModeratorAudit = async ( + c: Context, +): Promise<{ + condition: SQL; + set: ResolvedStaffPermissionSet; + userId: number; +}> => { + const moderator = await requireModerator(c); + const { set } = moderator; + const allowed = await checkStaffPermission(c, { + module: AUDIT_PERMISSION_MODULE, + permission: AUDIT_PERMISSIONS.view, + plugin: AUDIT_CORE_PLUGIN_ID, + type: "moderator", + }); + if (!allowed) throw new HTTPException(403, { message: "Forbidden" }); + + return { + condition: await moderatorAuditCondition(c, { + set, + userId: moderator.user.id, + }), + set, + userId: moderator.user.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..93c6e114c8 --- /dev/null +++ b/packages/vitnode/src/api/lib/audit/detail.ts @@ -0,0 +1,191 @@ +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, when the viewer may open it: the user or the content + * record in the AdminCP for an admin, the report in the ModeratorCP for a + * moderator. `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, + viewer: "admin" | "moderator", +): Promise => { + const { id, type } = entry.target; + if (id === null) return null; + + if (viewer === "moderator") { + return type === "report" ? `/modcp/reports/${id}` : 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`; moderators never get + * it. + */ +export const auditDetailOf = async ( + c: Context, + entry: FoundEntry, + { + canViewSensitive, + viewer, + }: { canViewSensitive: boolean; viewer: "admin" | "moderator" }, +): Promise => ({ + ...entry, + correlationId: canViewSensitive ? entry.correlationId : null, + ipAddress: canViewSensitive ? entry.ipAddress : null, + revision: viewer === "admin" ? await revisionOf(c, entry) : null, + targetHref: await targetHrefOf(c, entry, viewer), +}); diff --git a/packages/vitnode/src/api/lib/audit/query.ts b/packages/vitnode/src/api/lib/audit/query.ts index ef0d15f339..bdff86aa76 100644 --- a/packages/vitnode/src/api/lib/audit/query.ts +++ b/packages/vitnode/src/api/lib/audit/query.ts @@ -20,8 +20,6 @@ import { AUDIT_TARGET_TYPE_MAX_LENGTH, } from "@/lib/audit/const"; -import type { AuditDatabase } from "./write"; - export const AUDIT_DEFAULT_PAGE_SIZE = 25; export const AUDIT_MAX_PAGE_SIZE = 100; const AUDIT_SEARCH_MAX_LENGTH = 100; @@ -96,6 +94,29 @@ export const zodAuditPage = z.object({ }), }); +/** 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 @@ -261,14 +282,6 @@ const toListEntry = (row: ListRow): AuditListEntry => ({ }, }); -const baseQuery = (db: AuditDatabase) => - db - .select(listSelection) - .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)) - .$dynamic(); - /** * 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 @@ -289,6 +302,40 @@ export const listAuditLogs = async ( 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); @@ -297,7 +344,16 @@ export const listAuditLogs = async ( ); } - const rows = await baseQuery(c.get("db")) + 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); @@ -306,7 +362,11 @@ export const listAuditLogs = async ( const last = page.at(-1); return { - edges: page.map(row => toListEntry(row as ListRow)), + edges: page.map(row => ({ + ...toListEntry(row as ListRow), + changes: row.changes, + metadata: row.metadata, + })), pageInfo: { endCursor: last?.createdAt && last.id diff --git a/packages/vitnode/src/api/lib/audit/registry.ts b/packages/vitnode/src/api/lib/audit/registry.ts index 70b18cd19f..ab0f82206e 100644 --- a/packages/vitnode/src/api/lib/audit/registry.ts +++ b/packages/vitnode/src/api/lib/audit/registry.ts @@ -1,3 +1,5 @@ +import { z } from "@hono/zod-openapi"; + import type { AuditActionDefinition } from "@/lib/audit/actions"; import { CONTENT_AUDIT_ACTIONS, CORE_AUDIT_ACTIONS } from "@/lib/audit/actions"; @@ -42,3 +44,41 @@ export const collectAuditActions = ( 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, + })), + plugins: 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.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/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/detail.route.ts b/packages/vitnode/src/api/modules/admin/activity-logs/routes/detail.route.ts new file mode 100644 index 0000000000..1145da215c --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/activity-logs/routes/detail.route.ts @@ -0,0 +1,44 @@ +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, + ), + viewer: "admin", + }), + 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/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/moderation/moderation.module.ts b/packages/vitnode/src/api/modules/moderation/moderation.module.ts index 390c2a50af..3ae7107582 100644 --- a/packages/vitnode/src/api/modules/moderation/moderation.module.ts +++ b/packages/vitnode/src/api/modules/moderation/moderation.module.ts @@ -1,5 +1,10 @@ import { buildModule } from "@/api/builders"; +import { + moderationActivityDetailRoute, + moderationActivityFiltersRoute, + moderationActivityRoute, +} from "./routes/activity.routes"; import { approvalFiltersRoute, approveApprovalRoute, @@ -29,6 +34,9 @@ import { export const moderationModule = buildModule({ name: "moderation", routes: [ + moderationActivityRoute, + moderationActivityFiltersRoute, + moderationActivityDetailRoute, listReportReasonsRoute, createReportRoute, listReportsRoute, diff --git a/packages/vitnode/src/api/modules/moderation/routes/activity.routes.ts b/packages/vitnode/src/api/modules/moderation/routes/activity.routes.ts new file mode 100644 index 0000000000..9260d28dc0 --- /dev/null +++ b/packages/vitnode/src/api/modules/moderation/routes/activity.routes.ts @@ -0,0 +1,102 @@ +import { z } from "@hono/zod-openapi"; + +import { buildRoute } from "@/api/builders"; +import { requireModeratorAudit } from "@/api/lib/audit/access"; +import { auditDetailOf } from "@/api/lib/audit/detail"; +import { + findAuditLog, + listAuditLogs, + zodAuditDetail, + zodAuditPage, + zodAuditPageQuery, +} from "@/api/lib/audit/query"; +import { + auditFilterOptions, + zodAuditFilterOptions, +} from "@/api/lib/audit/registry"; + +export const moderationActivityRoute = buildRoute({ + route: { + method: "get", + path: "/activity", + description: + "Moderation activity the signed-in moderator may see: every moderator-area entry for a site-wide moderator, the entries of their own scopes otherwise", + request: { query: zodAuditPageQuery.omit({ area: true }) }, + responses: { + 200: { + content: { "application/json": { schema: zodAuditPage } }, + description: "One page of moderation activity", + }, + 401: { description: "Sign in first" }, + 403: { description: "Not a moderator with Activity Logs access" }, + }, + }, + handler: async c => { + const { condition } = await requireModeratorAudit(c); + const { cursor, first, ...filters } = c.req.valid("query"); + + return c.json( + await listAuditLogs(c, { access: condition, cursor, filters, first }), + 200, + ); + }, +}); + +export const moderationActivityDetailRoute = buildRoute({ + route: { + method: "get", + path: "/activity/{id}", + description: + "One moderation activity entry, under the same rule as the list - an entry from another scope is a 404, never a 403", + request: { + params: z.object({ + id: z.coerce.number().int().positive().openapi({ example: 1 }), + }), + }, + responses: { + 200: { + content: { "application/json": { schema: zodAuditDetail } }, + description: "The entry", + }, + 401: { description: "Sign in first" }, + 403: { description: "Not a moderator with Activity Logs access" }, + 404: { description: "No such entry, or not one this moderator may see" }, + }, + }, + handler: async c => { + const { condition } = await requireModeratorAudit(c); + const { id } = c.req.valid("param"); + const entry = await findAuditLog(c, { access: condition, id }); + if (!entry) return c.json({ error: "Not found" }, 404); + + return c.json( + await auditDetailOf(c, entry, { + canViewSensitive: false, + viewer: "moderator", + }), + 200, + ); + }, +}); + +export const moderationActivityFiltersRoute = buildRoute({ + route: { + method: "get", + path: "/activity/filters", + description: + "The plugins and declared actions the moderation activity filters offer", + responses: { + 200: { + content: { "application/json": { schema: zodAuditFilterOptions } }, + description: "Filter options", + }, + 401: { description: "Sign in first" }, + 403: { description: "Not a moderator with Activity Logs access" }, + }, + }, + handler: async c => { + await requireModeratorAudit(c); + + return c.json(auditFilterOptions(c.get("core")), 200); + }, +}); diff --git a/packages/vitnode/src/content/server/audit.ts b/packages/vitnode/src/content/server/audit.ts index 8ba2d741f7..68d5a18cb0 100644 --- a/packages/vitnode/src/content/server/audit.ts +++ b/packages/vitnode/src/content/server/audit.ts @@ -217,6 +217,7 @@ export const recordContentAudit = async ( const fields = audit.trackChanges ? changedFields.filter(path => !isExcludedPath(path, audit.excludeFields)) : []; + const itemKey = String(itemId); await writeAuditLog( c, @@ -233,7 +234,7 @@ export const recordContentAudit = async ( }, pluginId, revisionId, - target: { id: itemId, label, type: definition.id }, + target: { id: itemKey, label, type: definition.id }, }, tx, ); @@ -366,14 +367,23 @@ export const withContentSaveAudit = async ( 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 = - held.find(entry => entry.target.label !== null)?.target.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.target.label ?? label }, + target: { + ...entry.target, + label: + entry.metadata.locale === undefined + ? (label ?? entry.target.label) + : (entry.target.label ?? label), + }, }); const created = held.find( @@ -383,11 +393,24 @@ export const withContentSaveAudit = async ( 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, locales: contentAuditLocales(folded) }, + metadata: { + ...created.metadata, + ...(changedFields.length > 0 ? { changedFields } : {}), + locales: contentAuditLocales(folded), + }, }), tx, ); diff --git a/packages/vitnode/src/database/audit.ts b/packages/vitnode/src/database/audit.ts index 229403bd0d..ebd1217b6d 100644 --- a/packages/vitnode/src/database/audit.ts +++ b/packages/vitnode/src/database/audit.ts @@ -6,6 +6,7 @@ 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, @@ -78,3 +79,21 @@ export const core_audit_logs = camelCase.table.withRLS( ), ], ); + +/** + * 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/lib/audit/actions.ts b/packages/vitnode/src/lib/audit/actions.ts index 223c39e596..4d3353b96f 100644 --- a/packages/vitnode/src/lib/audit/actions.ts +++ b/packages/vitnode/src/lib/audit/actions.ts @@ -134,4 +134,6 @@ export const CORE_AUDIT_ACTIONS = defineAuditActions([ { 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/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/locales/en.json b/packages/vitnode/src/locales/en.json index 0faad31d27..1260d4281c 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}", @@ -2783,8 +2789,200 @@ "before": "Before", "after": "After" } + }, + "activity": { + "title": "Activity", + "desc": "What moderators did in the areas you moderate." } } + }, + "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": { @@ -2975,7 +3173,8 @@ "integrations": "Integrations", "files": "Files", "notifications": "Notifications", - "plugins": "Plugins" + "plugins": "Plugins", + "activity_logs": "Activity Logs" }, "ai": { "title": "Artificial Intelligence (AI)", @@ -4215,6 +4414,24 @@ }, "version_on_side": "{version} ({side})" } + }, + "activity_logs": { + "title": "Activity Logs", + "desc": "Who changed what, and when - across the AdminCP, moderators, plugins and content.", + "export": "Export CSV", + "export_error": "The export could not be downloaded. Try again in a moment.", + "settings": { + "title": "Retention", + "open": "Retention settings", + "desc": "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." + } } }, "debug": { diff --git a/packages/vitnode/src/pages/admin/system/activity-logs.tsx b/packages/vitnode/src/pages/admin/system/activity-logs.tsx new file mode 100644 index 0000000000..cf13b508f3 --- /dev/null +++ b/packages/vitnode/src/pages/admin/system/activity-logs.tsx @@ -0,0 +1,36 @@ +import type { PluginRoutePageProps } from "@/routing"; +import type { AdminActivityLogsRouteData } from "@/tanstack/admin/activity-logs/route"; +import type { ActivityRouteSearch } from "@/views/activity-logs/activity-search"; + +import { loadAdminActivityLogsRoute } from "@/tanstack/admin/activity-logs/route"; +import { AdminActivityLogsRouteContent } from "@/tanstack/admin/activity-logs/screen"; +import { adminBreadcrumb } from "@/tanstack/admin/breadcrumb"; +import { defineAdminRoute } from "@/tanstack/plugin-routes"; + +const AdminActivityLogsPage = ({ + loaderData, + navigate, + search, +}: PluginRoutePageProps) => ( + +); + +export const route = defineAdminRoute< + AdminActivityLogsRouteData, + ActivityRouteSearch +>({ + // `head` after `load`, always. + load: async ({ context, search, t }) => + await loadAdminActivityLogsRoute({ ...context, search, t }), + head: ({ loaderData }) => ({ ...loaderData }), + + breadcrumb: adminBreadcrumb({ + segments: ["core", "system", "activity-logs"], + }), +}); + +export default AdminActivityLogsPage; diff --git a/packages/vitnode/src/pages/modcp/activity.tsx b/packages/vitnode/src/pages/modcp/activity.tsx new file mode 100644 index 0000000000..6348074fef --- /dev/null +++ b/packages/vitnode/src/pages/modcp/activity.tsx @@ -0,0 +1,34 @@ +import type { PluginRoutePageProps } from "@/routing"; +import type { ActivityRouteSearch } from "@/views/activity-logs/activity-search"; + +import type { ModcpRouteData } from "../../tanstack/moderation/route"; + +import { loadModcpActivityRoute } from "../../tanstack/moderation/activity-route"; +import { ModcpActivityRouteContent } from "../../tanstack/moderation/activity-screen"; +import { ModcpActivityBreadcrumb } from "../../tanstack/moderation/breadcrumb"; +import { defineAuthenticatedRoute } from "../../tanstack/plugin-routes"; + +const ModcpActivityPage = ({ + loaderData, + navigate, + search, +}: PluginRoutePageProps) => ( + +); + +export const route = defineAuthenticatedRoute< + ModcpRouteData, + ActivityRouteSearch +>({ + load: async ({ context, search, t }) => + await loadModcpActivityRoute({ ...context, search, t }), + head: ({ loaderData }) => ({ robots: "noindex, nofollow", ...loaderData }), + + breadcrumb: ModcpActivityBreadcrumb, +}); + +export default ModcpActivityPage; diff --git a/packages/vitnode/src/routes.tsx b/packages/vitnode/src/routes.tsx index 6a74580d85..14accd6ec2 100644 --- a/packages/vitnode/src/routes.tsx +++ b/packages/vitnode/src/routes.tsx @@ -1,4 +1,5 @@ import { defineRoutes, index, layout, lazy, page } from "./routing"; +import { ADMIN_ACTIVITY_LOGS_NAMESPACES } from "./tanstack/admin/activity-logs/route"; import { ADMIN_AI_NAMESPACES } from "./tanstack/admin/ai/route"; import { normalizeAiHistoryRouteSearch, @@ -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 @@ -235,6 +237,12 @@ export const routes = defineRoutes([ component: lazy(() => import("./pages/modcp/approval")), pendingComponent: () => , }), + + page("activity", { + component: lazy(() => import("./pages/modcp/activity")), + pendingComponent: TablePendingSkeleton, + search: normalizeActivitySearch, + }), ], }), @@ -333,6 +341,14 @@ export const routes = defineRoutes([ ], }), + page("/admin/core/system/activity-logs", { + area: "admin", + component: lazy(() => import("./pages/admin/system/activity-logs")), + messages: ADMIN_ACTIVITY_LOGS_NAMESPACES, + pendingComponent: TablePendingSkeleton, + search: normalizeActivitySearch, + }), + page("/admin/core/system/files", { area: "admin", component: lazy(() => import("./pages/admin/system/files")), diff --git a/packages/vitnode/src/tanstack/admin/activity-logs/route.tsx b/packages/vitnode/src/tanstack/admin/activity-logs/route.tsx new file mode 100644 index 0000000000..e7a3b0128f --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/activity-logs/route.tsx @@ -0,0 +1,66 @@ +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/system/activity-logs`, as everything a route needs and nothing it owns. */ + +export const ADMIN_ACTIVITY_LOGS_NAMESPACES = [ + "admin.system.activity_logs", + "core.activity_logs", + "core.global", +] as const; + +export interface AdminActivityLogsRouteData { + description: string; + title: string; +} + +export const ACTIVITY_LOGS_MODULE = "activity_logs"; + +export const loadAdminActivityLogsRoute = async ({ + adminAccess, + queryClient, + search, + t, +}: AdminScreenContext & { + search: ActivityRouteSearch; + t: PluginRouteTranslator; +}): 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("admin"), + staleTime: "static", + }), + queryClient + .infiniteQuery({ + ...activityListQueryOptions({ + filters: activityFiltersOf(search), + viewer: "admin", + }), + staleTime: "static", + }) + .catch(() => undefined), + ]); + + return { + description: t("admin.system.activity_logs.desc"), + title: t("admin.system.activity_logs.title"), + }; +}; diff --git a/packages/vitnode/src/tanstack/admin/activity-logs/screen.tsx b/packages/vitnode/src/tanstack/admin/activity-logs/screen.tsx new file mode 100644 index 0000000000..19cd3ecf86 --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/activity-logs/screen.tsx @@ -0,0 +1,160 @@ +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 { 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 { AdminActivityLogsRouteData } from "./route"; + +import { RouteMessages } from "../../i18n/route-messages"; +import { useAdminPermission } from "../permissions"; +import { ACTIVITY_LOGS_MODULE, ADMIN_ACTIVITY_LOGS_NAMESPACES } from "./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 }: { search: ActivityRouteSearch }) => { + const t = useTranslations("admin.system.activity_logs"); + const tError = useTranslations("core.global.errors"); + const [isExporting, setIsExporting] = React.useState(false); + + return ( + + ); +}; + +const RetentionSheet = ({ canManage }: { canManage: boolean }) => { + const t = useTranslations("admin.system.activity_logs.settings"); + const [isOpen, setIsOpen] = React.useState(false); + + return ( + + }> + + {t("open")} + + + + {t("title")} + + {t("desc")} + + + + + + } + > + { + setIsOpen(false); + }} + /> + + + + ); +}; + +export interface AdminActivityLogsRouteProps extends AdminActivityLogsRouteData { + navigate: AdminTableNavigate; + search: ActivityRouteSearch; +} + +/** AdminCP -> System -> Activity Logs. */ +export const AdminActivityLogsRouteContent = ({ + description, + navigate, + search, + title, +}: AdminActivityLogsRouteProps) => { + const { data: options } = useSuspenseQuery( + activityFilterOptionsQueryOptions("admin"), + ); + 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), + }); + }} + viewer="admin" + /> +
+ + ); +}; 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/moderation/activity-route.ts b/packages/vitnode/src/tanstack/moderation/activity-route.ts new file mode 100644 index 0000000000..e78818d04c --- /dev/null +++ b/packages/vitnode/src/tanstack/moderation/activity-route.ts @@ -0,0 +1,70 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import { notFound } from "@tanstack/react-router"; + +import type { PluginRouteTranslator } from "@/routing"; +import type { ActivityRouteSearch } from "@/views/activity-logs/activity-search"; + +import { + activityFilterOptionsQueryOptions, + activityListQueryOptions, + ActivityRequestError, +} from "@/views/activity-logs/activity-query"; +import { activityFiltersOf } from "@/views/activity-logs/activity-search"; + +import type { AuthenticatedState } from "../auth/state"; +import type { ModcpRouteData } from "./route"; + +import { requireModeratorRoute } from "./route"; + +export const MODCP_ACTIVITY_NAMESPACES = [ + "core.activity_logs", + "core.global", + "core.moderation", +] as const; + +/** + * `/modcp/activity`. Being a moderator is not enough: the API answers 403 + * without the moderator `activity_logs` permission, and the page is then the + * same 404 a non-moderator gets. + */ +export const loadModcpActivityRoute = async ({ + auth, + queryClient, + search, + t, +}: { + auth: AuthenticatedState; + queryClient: QueryClient; + search: ActivityRouteSearch; + t: PluginRouteTranslator; +}): Promise => { + requireModeratorRoute(auth); + + try { + await queryClient.query({ + ...activityFilterOptionsQueryOptions("moderator"), + staleTime: "static", + }); + } catch (error) { + if (error instanceof ActivityRequestError && error.status === 403) { + // oxlint-disable-next-line typescript/only-throw-error + throw notFound(); + } + throw error; + } + await queryClient + .infiniteQuery({ + ...activityListQueryOptions({ + filters: activityFiltersOf(search), + viewer: "moderator", + }), + staleTime: "static", + }) + .catch(() => undefined); + + return { + description: t("core.moderation.modcp.activity.desc"), + title: t("core.moderation.modcp.activity.title"), + }; +}; diff --git a/packages/vitnode/src/tanstack/moderation/activity-screen.tsx b/packages/vitnode/src/tanstack/moderation/activity-screen.tsx new file mode 100644 index 0000000000..62ea544f58 --- /dev/null +++ b/packages/vitnode/src/tanstack/moderation/activity-screen.tsx @@ -0,0 +1,61 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; + +import type { ActivityRouteSearch } from "@/views/activity-logs/activity-search"; + +import { PageTitle } from "@/components/ui/page-title"; +import { activityPluginNamespaces } from "@/views/activity-logs/activity-labels"; +import { activityFilterOptionsQueryOptions } 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 { ModcpRouteData } from "./route"; + +import { RouteMessages } from "../i18n/route-messages"; +import { MODCP_ACTIVITY_NAMESPACES } from "./activity-route"; + +export interface ModcpActivityRouteProps extends ModcpRouteData { + navigate: (options: { + resetScroll: boolean; + search: ActivityRouteSearch; + }) => Promise; + search: ActivityRouteSearch; +} + +/** ModeratorCP -> Activity: the moderation log, scoped by the API to what this moderator moderates. */ +export const ModcpActivityRouteContent = ({ + description, + navigate, + search, + title, +}: ModcpActivityRouteProps) => { + const { data: options } = useSuspenseQuery( + activityFilterOptionsQueryOptions("moderator"), + ); + + return ( + +
+ + { + void navigate({ + resetScroll: false, + search: activitySearchOf(next), + }); + }} + viewer="moderator" + /> +
+
+ ); +}; diff --git a/packages/vitnode/src/tanstack/moderation/breadcrumb.tsx b/packages/vitnode/src/tanstack/moderation/breadcrumb.tsx index 2d28b347f8..90bc829e76 100644 --- a/packages/vitnode/src/tanstack/moderation/breadcrumb.tsx +++ b/packages/vitnode/src/tanstack/moderation/breadcrumb.tsx @@ -37,3 +37,9 @@ export const ModcpApprovalBreadcrumb = ({ return <>{t("title", { id: loaderData?.id ?? 0 })}; }; + +export const ModcpActivityBreadcrumb = () => { + const t = useTranslations("core.moderation.modcp.activity"); + + return <>{t("title")}; +}; 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..130a4143b1 --- /dev/null +++ b/packages/vitnode/src/views/activity-logs/activity-detail.tsx @@ -0,0 +1,225 @@ +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, ActivityViewer } 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, + viewer, +}: { + 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"]; + viewer: ActivityViewer; +}) => { + const t = useTranslations("core.activity_logs.detail"); + const labels = useActivityLabels(plugins); + const { data: entry, isError } = useQuery( + activityDetailQueryOptions({ id, viewer }), + ); + + 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..3070150a0c --- /dev/null +++ b/packages/vitnode/src/views/activity-logs/activity-filters.tsx @@ -0,0 +1,264 @@ +import { FilterXIcon, SearchIcon, XIcon } from "lucide-react"; +import React from "react"; +import { useTranslations } from "use-intl"; + +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 } from "@/lib/audit/const"; + +import type { + ActivityFilterOptions, + ActivityFilters, + ActivityViewer, +} from "./activity-query"; + +import { useActivityLabels } from "./activity-labels"; + +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, + viewer, +}: { + /** The name of the actor `filters.actorId` narrows to, for its chip. */ + actorName?: null | string; + filters: ActivityFilters; + onChange: (filters: ActivityFilters) => void; + options: ActivityFilterOptions; + viewer: ActivityViewer; +}) => { + 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) && + (viewer === "admin" || + entry.action.startsWith("report.") || + entry.action.startsWith("submission.") || + entry.action.startsWith("content.")), + ); + 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, viewer]); + + 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} + /> + {viewer === "admin" ? ( + { + set("area")(AUDIT_AREAS.find(area => area === value)); + }} + options={AUDIT_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..952c1159e7 --- /dev/null +++ b/packages/vitnode/src/views/activity-logs/activity-query.ts @@ -0,0 +1,294 @@ +import { + infiniteQueryOptions, + keepPreviousData, + queryOptions, +} from "@tanstack/react-query"; + +import type { AuditArea } 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; +} + +/** Which Activity Logs a screen reads: the AdminCP's, or a moderator's scoped view. */ +export type ActivityViewer = "admin" | "moderator"; + +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 of one viewer - invalidate it after a write. */ +export const activityQueryRoot = (viewer: ActivityViewer) => + [...ACTIVITY_ROOT, viewer] as const; + +const fetchAdminPage = async (filters: ActivityFilters, 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(); +}; + +const fetchModeratorPage = async ( + filters: ActivityFilters, + cursor?: string, +) => { + const { area: _area, ...query } = filters; + const response = await fetcher({ + plugin: manifest.id, + args: { query: { ...query, ...(cursor ? { cursor } : {}) } }, + method: "get", + module: "moderation", + path: "/activity", + }); + if (!response.ok) { + throw new ActivityRequestError(response.status, "moderation activity"); + } + + 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, + viewer, +}: { + filters: ActivityFilters; + viewer: ActivityViewer; +}) => + infiniteQueryOptions({ + getNextPageParam: (last: ActivityPage) => + last.pageInfo.hasNextPage + ? (last.pageInfo.endCursor ?? undefined) + : undefined, + initialPageParam: undefined as string | undefined, + placeholderData: keepPreviousData, + queryFn: async ({ pageParam }) => + viewer === "admin" + ? await fetchAdminPage(filters, pageParam) + : await fetchModeratorPage(filters, pageParam), + queryKey: [...activityQueryRoot(viewer), "list", filters], + retry: false, + staleTime: OPERATIONAL_STALE_TIME, + }); + +const fetchDetail = async (viewer: ActivityViewer, id: number) => { + const response = + viewer === "admin" + ? await fetcher({ + plugin: manifest.id, + args: { params: { id } }, + method: "get", + module: "admin/activity-logs", + path: "/{id}", + }) + : await fetcher({ + plugin: manifest.id, + args: { params: { id } }, + method: "get", + module: "moderation", + path: "/activity/{id}", + }); + if (!response.ok) { + throw new ActivityRequestError(response.status, "an activity entry"); + } + + return await response.json(); +}; + +export type ActivityDetail = Awaited>; + +export const activityDetailQueryOptions = ({ + id, + viewer, +}: { + id: number; + viewer: ActivityViewer; +}) => + queryOptions({ + queryFn: async () => await fetchDetail(viewer, id), + queryKey: [...activityQueryRoot(viewer), "detail", id], + retry: false, + staleTime: OPERATIONAL_STALE_TIME, + }); + +const fetchFilterOptions = async (viewer: ActivityViewer) => { + const response = + viewer === "admin" + ? await fetcher({ + plugin: manifest.id, + method: "get", + module: "admin/activity-logs", + path: "/filters", + }) + : await fetcher({ + plugin: manifest.id, + method: "get", + module: "moderation", + path: "/activity/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 = (viewer: ActivityViewer) => + queryOptions({ + queryFn: async () => await fetchFilterOptions(viewer), + queryKey: [...activityQueryRoot(viewer), "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("admin"), "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("admin"), "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: ActivityFilters, +): 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 = `activity-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..81c7f0e37c --- /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.system.activity_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("admin"), + }); + 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..c76520d5f1 --- /dev/null +++ b/packages/vitnode/src/views/activity-logs/activity-screen.tsx @@ -0,0 +1,153 @@ +import { useInfiniteQuery, useSuspenseQuery } from "@tanstack/react-query"; +import { CircleAlertIcon } from "lucide-react"; +import React from "react"; +import { useTranslations } from "use-intl"; + +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; +import { Skeleton } from "@/components/ui/skeleton"; + +import type { + ActivityEntry, + ActivityFilters, + ActivityViewer, +} 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 - the AdminCP's 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; + viewer: ActivityViewer; +} + +/** + * Filters, the list and the detail sheet of one viewer's Activity Logs - the + * same screen for the AdminCP and the ModeratorCP, fed by different endpoints + * that enforce their own access rules. + */ +export const ActivityScreen = ({ + actions, + entryId, + filters, + onNavigate, + viewer, +}: ActivityScreenProps) => { + const t = useTranslations("core.activity_logs"); + const { data: options } = useSuspenseQuery( + activityFilterOptionsQueryOptions(viewer), + ); + const list = useInfiniteQuery(activityListQueryOptions({ filters, viewer })); + 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} + viewer={viewer} + /> +
+ {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} + viewer={viewer} + /> +
+ ); +}; diff --git a/packages/vitnode/src/views/activity-logs/activity-search.ts b/packages/vitnode/src/views/activity-logs/activity-search.ts new file mode 100644 index 0000000000..3f21880c1e --- /dev/null +++ b/packages/vitnode/src/views/activity-logs/activity-search.ts @@ -0,0 +1,76 @@ +import { + AUDIT_ACTION_PATTERN, + AUDIT_AREAS, + AUDIT_TARGET_TYPE_PATTERN, +} from "@/lib/audit/const"; +import { asSearchValue } from "@/lib/table-params"; + +import type { ActivityFilters } from "./activity-query"; + +/** An Activity Logs URL: the filters, plus the entry whose sheet is open. */ +export interface ActivityRouteSearch extends ActivityFilters { + log?: number; +} + +const DAY = /^\d{4}-\d{2}-\d{2}$/; +const PLUGIN_ID = /^[\w@./-]{1,255}$/; +const TARGET_ID = /^[\w:.-]{1,64}$/; + +const positiveInt = (value: unknown): number | undefined => { + const raw = asSearchValue(value); + if (raw === undefined || !/^\d{1,15}$/.test(raw)) return undefined; + const number = Number(raw); + + return Number.isSafeInteger(number) && number > 0 ? number : undefined; +}; + +const matching = (value: unknown, pattern: RegExp, max = 255) => { + const raw = asSearchValue(value); + + return raw && raw.length <= max && pattern.test(raw) ? raw : undefined; +}; + +/** + * The route's search validator: keeps what it recognises, drops the rest, + * never throws - a hand-edited URL shows the unfiltered log rather than an + * error. The API validates again; this only keeps the URL tidy. + */ +export const normalizeActivitySearch = ( + input: Record, +): ActivityRouteSearch => { + const area = AUDIT_AREAS.find(value => value === asSearchValue(input.area)); + const search = asSearchValue(input.search)?.trim().slice(0, 100); + const entries: [keyof ActivityRouteSearch, unknown][] = [ + ["action", matching(input.action, AUDIT_ACTION_PATTERN, 150)], + ["actorId", positiveInt(input.actorId)], + ["area", area], + ["from", matching(input.from, DAY)], + ["log", positiveInt(input.log)], + ["pluginId", matching(input.pluginId, PLUGIN_ID)], + ["search", search === "" ? undefined : search], + ["targetId", matching(input.targetId, TARGET_ID, 64)], + ["targetType", matching(input.targetType, AUDIT_TARGET_TYPE_PATTERN, 100)], + ["to", matching(input.to, DAY)], + ]; + + return Object.fromEntries(entries.filter(([, value]) => value !== undefined)); +}; + +/** The filters of a route search - everything but the open sheet. */ +export const activityFiltersOf = ({ + log: _log, + ...filters +}: ActivityRouteSearch): ActivityFilters => filters; + +/** Filters and the open entry, back as route search. */ +export const activitySearchOf = ({ + entryId, + filters, +}: { + entryId: null | number; + filters: ActivityFilters; +}): ActivityRouteSearch => + normalizeActivitySearch({ + ...filters, + ...(entryId === null ? {} : { log: entryId }), + }); diff --git a/packages/vitnode/src/views/activity-logs/activity-sheet.tsx b/packages/vitnode/src/views/activity-logs/activity-sheet.tsx new file mode 100644 index 0000000000..487b963323 --- /dev/null +++ b/packages/vitnode/src/views/activity-logs/activity-sheet.tsx @@ -0,0 +1,75 @@ +import React from "react"; +import { useTranslations } from "use-intl"; + +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; + +import type { + ActivityEntry, + ActivityFilterOptions, + ActivityViewer, +} from "./activity-query"; + +import { ActivityDetailPending } from "./activity-detail-pending"; +import { useActivityLabels } from "./activity-labels"; + +const ActivityDetailContent = React.lazy( + async () => await import("./activity-detail"), +); + +/** + * The detail Sheet of one entry. Open while `entryId` is set - the screen + * keeps it in the URL, so a detail can be linked and survives a reload. The + * body is lazy: the list never pays for it. + */ +export const ActivitySheet = ({ + entry, + entryId, + onFilterActor, + onOpenChange, + plugins, + viewer, +}: { + /** The row it was opened from, for an instant title; absent on a direct link. */ + entry?: ActivityEntry; + entryId: null | number; + onFilterActor?: (actor: { id: number; name: string }) => void; + onOpenChange: (open: boolean) => void; + plugins: ActivityFilterOptions["plugins"]; + viewer: ActivityViewer; +}) => { + const t = useTranslations("core.activity_logs.detail"); + const labels = useActivityLabels(plugins); + + return ( + + + + + {entry ? labels.action(entry) : t("title")} + + + {entry ? labels.target(entry) : null} + + +
+ {entryId === null ? null : ( + }> + + + )} +
+
+
+ ); +}; diff --git a/packages/vitnode/src/views/activity-logs/activity-table.tsx b/packages/vitnode/src/views/activity-logs/activity-table.tsx new file mode 100644 index 0000000000..de0da64332 --- /dev/null +++ b/packages/vitnode/src/views/activity-logs/activity-table.tsx @@ -0,0 +1,189 @@ +import { ChevronRightIcon, HistoryIcon } from "lucide-react"; +import { useTranslations } from "use-intl"; + +import { DateFormat } from "@/components/date-format"; +import { Button } from "@/components/ui/button"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +import type { ActivityEntry, ActivityFilterOptions } from "./activity-query"; + +import { + ActivityActor, + ActivityAreaBadge, + useActivityLabels, +} from "./activity-labels"; + +export interface ActivityTableProps { + entries: ActivityEntry[]; + fetchNextPage: () => void; + hasNextPage: boolean; + /** A refetch for new filters is under way: the rows stay, dimmed. */ + isFetching: boolean; + isFetchingNextPage: boolean; + onOpen: (entry: ActivityEntry) => void; + plugins: ActivityFilterOptions["plugins"]; +} + +export const ActivityEmpty = () => { + const t = useTranslations("core.activity_logs"); + + return ( + + + + + + {t("empty_title")} + {t("empty_desc")} + + + ); +}; + +/** + * The entries, newest first: cards on a phone, a table from `md` up. Every + * entry opens its detail sheet from a real button, so it works from the + * keyboard; "Load older activity" follows the keyset cursor. + */ +export const ActivityTable = ({ + entries, + fetchNextPage, + hasNextPage, + isFetching, + isFetchingNextPage, + onOpen, + plugins, +}: ActivityTableProps) => { + const t = useTranslations("core.activity_logs"); + const labels = useActivityLabels(plugins); + + if (entries.length === 0) return ; + + return ( +
+
    + {entries.map(entry => ( +
  • + +
  • + ))} +
+ +
+ + + + {t("columns.date")} + {t("columns.actor")} + {t("columns.area")} + {t("columns.plugin")} + {t("columns.action")} + {t("columns.target")} + + {t("columns.details")} + + + + + {entries.map(entry => ( + { + onOpen(entry); + }} + > + + + + + + + + + + + {labels.plugin(entry.pluginId)} + + + {labels.action(entry)} + + + {labels.target(entry)} + + + + + + ))} + +
+
+ + {hasNextPage ? ( +
+ +
+ ) : null} + + {isFetching ? t("loading") : ""} + +
+ ); +}; diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx index bc4530c43d..1423cb7f38 100644 --- a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx +++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx @@ -187,6 +187,11 @@ const coreNavGroup = (): AdminNavGroupDeclaration => ({ href: "/admin/core/system/integrations", permission: core("system"), }, + { + title: key("admin.global.nav.system.activity_logs"), + href: "/admin/core/system/activity-logs", + permission: core("activity_logs"), + }, { title: key("admin.global.nav.system.files"), href: "/admin/core/system/files", diff --git a/packages/vitnode/src/views/admin/views/content/actions/activity-action.tsx b/packages/vitnode/src/views/admin/views/content/actions/activity-action.tsx new file mode 100644 index 0000000000..721dec90c2 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/activity-action.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import { useTranslations } from "use-intl"; + +import type { ContentId } from "@/content/ids"; + +import type { ContentPanelProps } from "./content-panel"; + +import { ContentPanel } from "./content-panel"; + +const ActivityHistory = React.lazy( + async () => + await import("@/views/activity-logs/activity-history").then(mod => ({ + default: mod.ActivityHistory, + })), +); + +/** + * "Activity" on a content row: the record's entries from the central Activity + * Logs - who created, edited, published or moderated it - without leaving the + * list. Separate from "History", which shows what its content used to be. + */ +export const ActivityContentPanel = ({ + contentTypeId, + id, + pluginId, + singular, + ...panel +}: ContentPanelProps & { + contentTypeId: string; + id: ContentId; + pluginId: string; + singular: string; +}) => { + const t = useTranslations("core.activity_logs.history"); + + return ( + +
+ +
+
+ ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/actions/row-actions-model.test.ts b/packages/vitnode/src/views/admin/views/content/actions/row-actions-model.test.ts index 1fc7f7f7a7..93ec411d08 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/row-actions-model.test.ts +++ b/packages/vitnode/src/views/admin/views/content/actions/row-actions-model.test.ts @@ -37,6 +37,7 @@ describe("contentRowActionIds", () => { "preview", "schedule", "history", + "activity", "delivery", "delete", ]); @@ -144,6 +145,7 @@ describe("duplicate and visibility", () => { "preview", "schedule", "history", + "activity", "delivery", "duplicate", "visibility", @@ -171,7 +173,7 @@ describe("duplicate and visibility", () => { canView: true, duplication: true, }), - ).toEqual(["duplicate"]); + ).toEqual(["activity", "duplicate"]); }); it("gates hiding on can_hide alone - not on can_publish", () => { diff --git a/packages/vitnode/src/views/admin/views/content/actions/row-actions-model.ts b/packages/vitnode/src/views/admin/views/content/actions/row-actions-model.ts index 8929a25cd7..ef0225689f 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/row-actions-model.ts +++ b/packages/vitnode/src/views/admin/views/content/actions/row-actions-model.ts @@ -4,6 +4,7 @@ export const CONTENT_ROW_ACTION_IDS = [ "preview", "schedule", "history", + "activity", "delivery", "duplicate", "visibility", @@ -92,6 +93,8 @@ const featureOf = ( }: ContentRowActionInput, ): boolean => { switch (id) { + // Every content type is audited, so every one has a history to show. + case "activity": case "delete": return true; case "delivery": diff --git a/packages/vitnode/src/views/moderation/modcp/modcp-nav.tsx b/packages/vitnode/src/views/moderation/modcp/modcp-nav.tsx index a715b6897f..823fc72f3b 100644 --- a/packages/vitnode/src/views/moderation/modcp/modcp-nav.tsx +++ b/packages/vitnode/src/views/moderation/modcp/modcp-nav.tsx @@ -1,29 +1,54 @@ import { useQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import { cn } from "cn"; -import { ClipboardCheckIcon, FlagIcon } from "lucide-react"; +import { ClipboardCheckIcon, FlagIcon, HistoryIcon } from "lucide-react"; import { useTranslations } from "use-intl"; import { SideNav, SideNavLink, SideNavList } from "@/components/side-nav"; +import { manifest } from "@/manifest"; +import { + holdsPagePermission, + moderatorPermissionsQueryOptions, +} from "@/tanstack/widgets/query"; import { modcpApprovalsQueryOptions, modcpReportsQueryOptions, } from "./modcp-query"; -export type ModcpNavKey = "approvals" | "reports"; +export type ModcpNavKey = "activity" | "approvals" | "reports"; interface ModcpNavItem { href: string; icon: React.ComponentType<{ className?: string }>; key: ModcpNavKey; + /** Shown only to moderators holding it. The page and its API check it again. */ + permission?: { module: string; permission: string; plugin: string }; } export const MODCP_NAV_ITEMS: readonly ModcpNavItem[] = [ { href: "/modcp/reports", icon: FlagIcon, key: "reports" }, { href: "/modcp/approvals", icon: ClipboardCheckIcon, key: "approvals" }, + { + href: "/modcp/activity", + icon: HistoryIcon, + key: "activity", + permission: { + module: "activity_logs", + permission: "can_view", + plugin: manifest.id, + }, + }, ]; +const useNavItems = (): readonly ModcpNavItem[] => { + const { data: held } = useQuery(moderatorPermissionsQueryOptions()); + + return MODCP_NAV_ITEMS.filter( + item => !item.permission || holdsPagePermission(held, item.permission), + ); +}; + const OPEN_REPORTS_FILTER = { assigned: "any", page: 1, @@ -86,6 +111,7 @@ const ModcpNavChip = ({ export const ModcpNavContent = ({ pathname }: { pathname: string }) => { const t = useTranslations("core.moderation.modcp"); const counts = useNavCounts(); + const items = useNavItems(); return ( <> @@ -94,7 +120,7 @@ export const ModcpNavContent = ({ pathname }: { pathname: string }) => { className="-mx-4 overflow-x-auto px-4 md:hidden" >