From bb5ad7b42215584f5720f01553b2c9ccb74a3beb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 17:57:56 +0000 Subject: [PATCH] feat(audit): unified Activity Logs for admins, moderators, plugins and content One audit trail for who changed what, when and to which resource. - core_audit_logs and core_audit_settings tables with a migration; indexed for newest-first keyset paging, actor, plugin, action, area, one target's history and moderation scope - Transaction-aware audit service: c.get("audit").record() / .diff(), writeAuditLog, withAuditContext frames (actor, area, metadata, scope, correlation id, intercept), trusted actor resolution from the session, sanitized and bounded changes and metadata, sensitive keys redacted - Content Engine audit written inside each write's transaction: create, edit, delete, duplicate, publish, hide, restore, schedule and every translation change, linked to the revision it created; per-type `audit` option (excludeFields, trackChanges, or false) - Core integrations: roles, staff permissions, users, AI settings and access, notifications, report reasons, cron runs, report workflow, approvals and scheduled publishing (attributed to the system) - Payments: payment.created (the customer) and payment.processing / succeeded / failed / expired (the system), written in the same transaction as the status change; amount, currency, provider, reference and charging plugin in the metadata - Plugin API: defineAuditActions + buildApiPlugin({ auditActions }) for the action filter, labels from {pluginId}.audit.actions.* - AdminCP -> Staff -> Administrators / Moderators -> Logs: filters, keyset "load more", detail sheet with changes and revision diff, CSV export (formula-escaped) and retention settings; Area column only when the list mixes areas; Activity panel on every content row - Admin activity_logs permissions: can_view, can_view_sensitive, can_export, can_manage_retention; daily audit-retention cron - Docs under dev/audit-logs and content-engine/activity-logs, with screenshots; tests for sanitizing, diffs, CSV, access rules, routes and recording paths Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PSeRx3Xr1PGRJ5q7wvs7jK --- .../migration.sql | 38 + .../snapshot.json | 23468 ++++++++++++++++ .../activity-logs-content-panel.webp | Bin 0 -> 64814 bytes .../docs/content-engine/activity-logs.mdx | 152 + .../web/content/docs/content-engine/meta.json | 1 + .../dev/audit-logs/access-and-retention.mdx | 76 + .../audit-logs/activity-logs-admin-list.webp | Bin 0 -> 126696 bytes .../activity-logs-detail-sheet.webp | Bin 0 -> 56340 bytes .../web/content/docs/dev/audit-logs/index.mdx | 83 + .../web/content/docs/dev/audit-logs/meta.json | 11 + .../dev/audit-logs/record-plugin-events.mdx | 172 + .../content/docs/dev/audit-logs/reference.mdx | 159 + apps/web/content/docs/dev/meta.json | 1 + apps/web/content/docs/dev/payments/index.mdx | 24 +- apps/web/src/page-cache.gen.ts | 2 + .../src/api/admin-staff-permission.test.ts | 2 + .../vitnode/src/api/lib/ai/settings-store.ts | 2 +- packages/vitnode/src/api/lib/audit/access.ts | 26 + packages/vitnode/src/api/lib/audit/context.ts | 119 + packages/vitnode/src/api/lib/audit/detail.ts | 184 + packages/vitnode/src/api/lib/audit/labels.ts | 102 + packages/vitnode/src/api/lib/audit/query.ts | 447 + .../vitnode/src/api/lib/audit/registry.ts | 92 + .../src/api/lib/audit/retention.test.ts | 86 + .../vitnode/src/api/lib/audit/retention.ts | 47 + .../vitnode/src/api/lib/audit/settings.ts | 34 + .../vitnode/src/api/lib/audit/write.test.ts | 284 + 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 | 129 + .../src/api/models/notifications/admin.ts | 7 + packages/vitnode/src/api/models/payments.ts | 142 +- .../activity-logs.admin.module.ts | 25 + .../routes/activity-logs.routes.test.ts | 334 + .../activity-logs/routes/detail.route.ts | 43 + .../activity-logs/routes/entity.route.ts | 73 + .../activity-logs/routes/export.route.ts | 82 + .../activity-logs/routes/filters.route.ts | 22 + .../admin/activity-logs/routes/list.route.ts | 29 + .../activity-logs/routes/settings.route.ts | 85 + .../src/api/modules/admin/admin.module.ts | 2 + .../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.test.ts | 114 + .../admin/roles/routes/create.route.ts | 78 +- .../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/staff-entry-routes.test.ts | 56 +- .../staff/routes/update-permissions.route.ts | 48 +- .../modules/admin/users/lib/audit-changes.ts | 50 + .../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 | 113 +- .../admin/users/routes/verify-email.route.ts | 40 +- .../helpers/execute-content-schedule.test.ts | 49 + .../helpers/execute-content-schedule.ts | 29 +- .../src/api/modules/cron/cron.module.ts | 3 +- .../modules/cron/cron/audit-retention.cron.ts | 23 + .../src/api/modules/cron/routes/cron.route.ts | 13 +- .../src/api/modules/moderation/lib/audit.ts | 91 + .../api/modules/moderation/reports.test.ts | 34 + .../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 | 6 + .../src/content/approvals/server/decide.ts | 57 +- .../vitnode/src/content/define-audit.test.ts | 50 + packages/vitnode/src/content/define.ts | 44 + packages/vitnode/src/content/index.ts | 2 + .../vitnode/src/content/server/audit.test.ts | 148 + packages/vitnode/src/content/server/audit.ts | 431 + .../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 | 89 +- .../vitnode/src/content/server/service.ts | 299 +- .../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 | 99 + packages/vitnode/src/database/relations.ts | 2 + .../vitnode/src/lib/audit/actions.test.ts | 37 + packages/vitnode/src/lib/audit/actions.ts | 148 + packages/vitnode/src/lib/audit/const.ts | 109 + packages/vitnode/src/lib/audit/csv.test.ts | 38 + packages/vitnode/src/lib/audit/csv.ts | 33 + packages/vitnode/src/lib/audit/diff.test.ts | 80 + packages/vitnode/src/lib/audit/diff.ts | 104 + .../vitnode/src/lib/audit/sanitize.test.ts | 197 + packages/vitnode/src/lib/audit/sanitize.ts | 212 + packages/vitnode/src/locales/en.json | 235 +- .../src/pages/admin/staff/admins/logs.tsx | 39 + .../src/pages/admin/staff/moderators/logs.tsx | 39 + packages/vitnode/src/routes.tsx | 18 + .../admin/content/editorial/panels.tsx | 34 + .../tanstack/admin/content/row-actions.tsx | 2 + .../src/tanstack/admin/staff/breadcrumbs.tsx | 21 + .../vitnode/src/tanstack/admin/staff/index.ts | 9 + .../src/tanstack/admin/staff/logs-route.tsx | 69 + .../src/tanstack/admin/staff/logs-screen.tsx | 174 + .../src/tanstack/admin/staff/screen.tsx | 31 +- packages/vitnode/src/tests/audit-recorder.ts | 31 + packages/vitnode/src/tests/revisions-table.ts | 29 +- .../views/activity-logs/activity-changes.tsx | 111 + .../activity-logs/activity-detail-pending.tsx | 25 + .../views/activity-logs/activity-detail.tsx | 215 + .../views/activity-logs/activity-filters.tsx | 273 + .../views/activity-logs/activity-history.tsx | 181 + .../views/activity-logs/activity-labels.tsx | 207 + .../src/views/activity-logs/activity-query.ts | 241 + .../activity-logs/activity-retention-form.tsx | 105 + .../views/activity-logs/activity-screen.tsx | 152 + .../activity-logs/activity-search.test.ts | 47 + .../views/activity-logs/activity-search.ts | 96 + .../views/activity-logs/activity-sheet.tsx | 68 + .../views/activity-logs/activity-table.tsx | 193 + .../views/content/actions/activity-action.tsx | 52 + .../content/actions/row-actions-model.test.ts | 4 +- .../content/actions/row-actions-model.ts | 3 + .../admin/views/core/staff/staff-model.ts | 4 + 129 files changed, 33180 insertions(+), 436 deletions(-) create mode 100644 apps/api/migrations/20261030172633_add_core_audit_logs/migration.sql create mode 100644 apps/api/migrations/20261030172633_add_core_audit_logs/snapshot.json create mode 100644 apps/web/content/docs/content-engine/activity-logs-content-panel.webp create mode 100644 apps/web/content/docs/content-engine/activity-logs.mdx create mode 100644 apps/web/content/docs/dev/audit-logs/access-and-retention.mdx create mode 100644 apps/web/content/docs/dev/audit-logs/activity-logs-admin-list.webp create mode 100644 apps/web/content/docs/dev/audit-logs/activity-logs-detail-sheet.webp 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/context.ts create mode 100644 packages/vitnode/src/api/lib/audit/detail.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/retention.test.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/lib/audit/write.test.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/activity-logs/activity-logs.admin.module.ts create mode 100644 packages/vitnode/src/api/modules/admin/activity-logs/routes/activity-logs.routes.test.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/admin/roles/routes/create.route.test.ts create mode 100644 packages/vitnode/src/api/modules/admin/users/lib/audit-changes.ts create mode 100644 packages/vitnode/src/api/modules/cron/cron/audit-retention.cron.ts create mode 100644 packages/vitnode/src/api/modules/moderation/lib/audit.ts create mode 100644 packages/vitnode/src/content/define-audit.test.ts create mode 100644 packages/vitnode/src/content/server/audit.test.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.test.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/csv.test.ts create mode 100644 packages/vitnode/src/lib/audit/csv.ts create mode 100644 packages/vitnode/src/lib/audit/diff.test.ts create mode 100644 packages/vitnode/src/lib/audit/diff.ts create mode 100644 packages/vitnode/src/lib/audit/sanitize.test.ts create mode 100644 packages/vitnode/src/lib/audit/sanitize.ts create mode 100644 packages/vitnode/src/pages/admin/staff/admins/logs.tsx create mode 100644 packages/vitnode/src/pages/admin/staff/moderators/logs.tsx create mode 100644 packages/vitnode/src/tanstack/admin/staff/logs-route.tsx create mode 100644 packages/vitnode/src/tanstack/admin/staff/logs-screen.tsx create mode 100644 packages/vitnode/src/tests/audit-recorder.ts 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.test.ts 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/20261030172633_add_core_audit_logs/migration.sql b/apps/api/migrations/20261030172633_add_core_audit_logs/migration.sql new file mode 100644 index 0000000000..49ec5698d3 --- /dev/null +++ b/apps/api/migrations/20261030172633_add_core_audit_logs/migration.sql @@ -0,0 +1,38 @@ +CREATE TABLE "core_audit_logs" ( + "id" bigserial PRIMARY KEY, + "pluginId" varchar(255) NOT NULL, + "area" varchar(16) NOT NULL, + "action" varchar(150) NOT NULL, + "actorType" varchar(16) NOT NULL, + "actorId" integer, + "actorName" varchar(255), + "targetType" varchar(100) NOT NULL, + "targetId" varchar(64), + "targetLabel" varchar(255), + "changes" jsonb, + "metadata" jsonb DEFAULT '{}' NOT NULL, + "contentTypeId" varchar(100), + "revisionId" integer, + "scopeFamily" varchar(100), + "scopeId" varchar(64), + "ipAddress" varchar(45), + "correlationId" varchar(64), + "createdAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "core_audit_logs" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "core_audit_settings" ( + "id" integer PRIMARY KEY DEFAULT 1, + "retentionDays" integer DEFAULT 365 NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "core_audit_settings" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE INDEX "core_audit_logs_created_at_id_idx" ON "core_audit_logs" ("createdAt","id");--> statement-breakpoint +CREATE INDEX "core_audit_logs_actor_idx" ON "core_audit_logs" ("actorId","createdAt");--> statement-breakpoint +CREATE INDEX "core_audit_logs_plugin_idx" ON "core_audit_logs" ("pluginId","createdAt");--> statement-breakpoint +CREATE INDEX "core_audit_logs_action_idx" ON "core_audit_logs" ("action","createdAt");--> statement-breakpoint +CREATE INDEX "core_audit_logs_area_idx" ON "core_audit_logs" ("area","createdAt");--> statement-breakpoint +CREATE INDEX "core_audit_logs_target_idx" ON "core_audit_logs" ("pluginId","targetType","targetId","createdAt");--> statement-breakpoint +CREATE INDEX "core_audit_logs_scope_idx" ON "core_audit_logs" ("scopeFamily","scopeId","createdAt");--> statement-breakpoint +ALTER TABLE "core_audit_logs" ADD CONSTRAINT "core_audit_logs_actorId_core_users_id_fkey" FOREIGN KEY ("actorId") REFERENCES "core_users"("id") ON DELETE SET NULL ON UPDATE CASCADE; \ No newline at end of file diff --git a/apps/api/migrations/20261030172633_add_core_audit_logs/snapshot.json b/apps/api/migrations/20261030172633_add_core_audit_logs/snapshot.json new file mode 100644 index 0000000000..f9d2338ea6 --- /dev/null +++ b/apps/api/migrations/20261030172633_add_core_audit_logs/snapshot.json @@ -0,0 +1,23468 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "ac6bc2f3-4afc-4768-be17-a5b36fd6180e", + "prevIds": [ + "109c64b2-df7c-4ff9-9d36-4a9baba53807", + "4f7f60d7-9030-4dba-af3c-549a3ec2fa80" + ], + "ddl": [ + { + "isRlsEnabled": true, + "name": "core_admin_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_admin_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_action_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_budget_periods", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_calls", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_cost_adjustments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_reservations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_role_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_role_policies", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_runs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_translation_sources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_user_overrides", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_audit_logs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_audit_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_containers", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_documents", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_drafts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_field_locks", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_submissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_file_refs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_revisions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_schedules", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_slug_history", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_cron", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_admin_dashboard", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files_alt", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files_alt_analysis", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files_alt_state", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_languages", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_languages_words", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_logs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderation_report_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderation_report_reasons", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderation_reports", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderators_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_deliveries", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_receipts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_user_state", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notifications", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_page_layouts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkey_challenges", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_payments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_payments_webhook_events", + "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_sso_attempts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sso_pending", + "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_comments", + "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": "forum_comments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "forum_topics", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_bulletins", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_bulletins_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_comments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_events_gallery", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_events_related_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_events_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_events_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_events_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_explainers", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_explainers_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_explainers_faq", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_explainers_related_explainers", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_explainers_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_explainers_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_letters", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_pages", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_stories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_stories_gallery", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_tags_related_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "newsroom_tags_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "payments_demo_purchases", + "entityType": "tables", + "schema": "public" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actionKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fallbackModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxInputCharacters", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxOutputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "timeoutMs", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxRetries", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxSteps", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dailyLimit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "instructions", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "unit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodStart", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodEnd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "limitAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "spentAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "unknownCount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "attempt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerRequestId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "errorCode", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheReadTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheWriteTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reasoningTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "images", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costReason", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pricingVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pricingSnapshot", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finishedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reconciledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "callId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "previousCostUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "previousSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "newCostUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "newSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deltaUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "budgetPeriodId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "amount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'active'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "settledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "permission", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "granted", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dailyLimit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unlimited", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actionKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorType", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resourceType", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resourceId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "errorCode", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "promptVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pointsConversionVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheReadTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheWriteTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reasoningTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "chargedUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "chargedPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "settlement", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "leaseExpiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accepted", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "acceptedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finishedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "settledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyBudgetUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "systemMonthlyBudgetUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "defaultMonthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "pointsConversionVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "userRequestsPerMinute", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2", + "generated": null, + "identity": null, + "name": "userConcurrency", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2", + "generated": null, + "identity": null, + "name": "systemConcurrency", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "365", + "generated": null, + "identity": null, + "name": "historyRetentionDays", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "altEnabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "altBatchSize", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "altScanCursor", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "altScanStartedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceLocale", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedById", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unlimited", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "blocked", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "area", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(150)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorType", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorName", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetType", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetId", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetLabel", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "changes", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revisionId", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeFamily", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeId", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(45)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "correlationId", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_audit_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_audit_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "365", + "generated": null, + "identity": null, + "name": "retentionDays", + "entityType": "columns", + "schema": "public", + "table": "core_audit_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_audit_settings" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "definitionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parentId", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "policies", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(35)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseVersion", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "values", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "baseVersion", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "acquiredAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerDefinitionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "values", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseValues", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseVersion", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseUpdatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reviewerId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "decidedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "operation", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "snapshot", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "changedFields", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'system'", + "generated": null, + "identity": null, + "name": "actorType", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorUserId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "restoredFromRevisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scheduledFor", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "generation", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "createdBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "effectsError", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "retiredAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastRun", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "module", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nextRun", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "schedule", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "widgets", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "folder", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mimeType", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "size", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'automatic'", + "generated": null, + "identity": null, + "name": "altPolicy", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "text", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedById", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'UTC'", + "generated": null, + "identity": null, + "name": "timezone", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "time24", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginCode", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tableName", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "variable", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(45)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'GET'", + "generated": null, + "identity": null, + "name": "method", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'localhost'", + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "500", + "generated": null, + "identity": null, + "name": "statusCode", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reportId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "requiresDetails", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetType", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reporterId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "snapshot", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetTitle", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'new'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "assigneeId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolution", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolutionNote", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolvedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolvedById", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeFamily", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "openKey", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mode", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodStart", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodEnd", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "itemCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerMessageId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "skipReason", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sentAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "schemaVersion", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "recipientIds", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowSelf", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "recipientCursor", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "deliveredCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "notificationId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seq", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailPending", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "emailDeliveryId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "unreadCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "revision", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "preferences", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "groupBucket", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "latestEventId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "eventCount", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "activitySeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "readSeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "readAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "archivedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastActivityAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "zones", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ceremony", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "challenge", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(1024)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credentialId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicKey", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "counter", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "transports", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceType", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "backedUp", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(36)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "aaguid", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastUsedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reference", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerType", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "amount", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "varchar(3)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "currency", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerStatus", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "checkoutId", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "checkoutUrl", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "livemode", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "errorCode", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "syncedAt", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_payments" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_payments_webhook_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_payments_webhook_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_payments_webhook_events" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "paymentId", + "entityType": "columns", + "schema": "public", + "table": "core_payments_webhook_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "receivedAt", + "entityType": "columns", + "schema": "public", + "table": "core_payments_webhook_events" + }, + { + "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": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowBrowseAnonymously", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "forceBrowseAnonymously", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowEditOwnContent", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "editOwnContentMinutes", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowDeleteOwnContent", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowHideOwnContent", + "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_sso_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "stateHash", + "entityType": "columns", + "schema": "public", + "table": "core_sso_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bindingHash", + "entityType": "columns", + "schema": "public", + "table": "core_sso_attempts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_sso_attempts" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "codeVerifier", + "entityType": "columns", + "schema": "public", + "table": "core_sso_attempts" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nonce", + "entityType": "columns", + "schema": "public", + "table": "core_sso_attempts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_sso_attempts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_sso_attempts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerUsername", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "profile", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "confirmHash", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "confirmExpiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "confirmSends", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "confirmSentAt", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "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": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastSeenAt", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "hideOnlineStatus", + "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": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "codeVerifier", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nonce", + "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_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenReason", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deletedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deletedBy", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "article", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parent", + "entityType": "columns", + "schema": "public", + "table": "blog_comments" + }, + { + "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": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenReason", + "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": "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(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenReason", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deletedAt", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deletedBy", + "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(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenReason", + "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" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "newsroom_articles" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_categories" + }, + { + "type": "varchar(80)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "newsroom_categories" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenReason", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deletedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deletedBy", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "article", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parent", + "entityType": "columns", + "schema": "public", + "table": "newsroom_comments" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": { + "type": "byDefault", + "name": "newsroom_events_id_seq", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": 1, + "cycle": false + }, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenReason", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "primaryTag", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startsAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "syndicationIndexable", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syndicationNoIndex", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "syndicationPriority", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "question", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "answer", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoTitle", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoDescription", + "entityType": "columns", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenReason", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "desk", + "entityType": "columns", + "schema": "public", + "table": "newsroom_letters" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "newsroom_pages" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "excerpt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "views", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "noIndex", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "animation", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenReason", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "payments_demo_purchases" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "payments_demo_purchases" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "payments_demo_purchases" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "payments_demo_purchases" + }, + { + "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": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "reference", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "status IN ('pending', 'processing')", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_payments_open_reference_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_payments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "providerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "checkoutId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"checkoutId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_payments_checkout_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_payments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "reference", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_payments_reference_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_payments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_payments_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_payments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "status IN ('pending', 'processing')", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_payments_open_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_payments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "receivedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_payments_webhook_events_received_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_payments_webhook_events" + }, + { + "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": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sso_attempts_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sso_attempts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sso_pending_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sso_pending" + }, + { + "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": "article", + "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": "blog_comments_article_parent_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_comments_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_comments_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "article", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_comments_article_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parent", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_comments_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_comments_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_comments_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_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": "blog_comments_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_comments_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deletedBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_comments_deleted_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_comments" + }, + { + "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": "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": "deletedBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_deleted_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": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_articles_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_articles_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_articles_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_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": "newsroom_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_bulletins_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_bulletins_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "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": "newsroom_bulletins_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_bulletins" + }, + { + "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": "newsroom_bulletins_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_bulletins_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": "newsroom_bulletins_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_categories_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "article", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_article_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parent", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_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": "newsroom_comments_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deletedBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_comments_deleted_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "primaryTag", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_primary_tag_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_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": "newsroom_events_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_events_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_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": "newsroom_events_gallery_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_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": "newsroom_events_gallery_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_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": "newsroom_events_related_events_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_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": "newsroom_events_related_events_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_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": "newsroom_events_sessions_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_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": "newsroom_events_tags_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_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": "newsroom_events_tags_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_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": "newsroom_events_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_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": "newsroom_events_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "syndicationPriority", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_syndication_priority_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "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": "newsroom_explainers_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers" + }, + { + "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": "newsroom_explainers_categories_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_categories_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_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": "newsroom_explainers_faq_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_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": "newsroom_explainers_related_explainers_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_related_explainers_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "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": "newsroom_explainers_tags_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_explainers_tags_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_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": "newsroom_explainers_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_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": "newsroom_explainers_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_letters_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_letters_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "desk", + "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": "newsroom_letters_desk_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_letters_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_letters_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_letters" + }, + { + "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": "newsroom_letters_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_letters_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_pages_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_pages_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_pages_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_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": "newsroom_pages_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_pages" + }, + { + "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": "newsroom_stories_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "code", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_code_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "animation", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_animation_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "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": "newsroom_stories_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories" + }, + { + "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": "newsroom_stories_gallery_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_stories_gallery_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_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": "newsroom_tags_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "newsroom_tags_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_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": "newsroom_tags_related_tags_position_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_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": "newsroom_tags_related_tags_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_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": "newsroom_tags_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_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": "newsroom_tags_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "payments_demo_purchases_user_item_unique", + "entityType": "indexes", + "schema": "public", + "table": "payments_demo_purchases" + }, + { + "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": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_payments_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_payments" + }, + { + "nameExplicit": false, + "columns": [ + "paymentId" + ], + "schemaTo": "public", + "tableTo": "core_payments", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_payments_webhook_events_paymentId_core_payments_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_payments_webhook_events" + }, + { + "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_comments_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": false, + "columns": [ + "deletedBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "blog_comments_deletedBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "blog_comments_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": false, + "columns": [ + "article" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_comments_article_blog_posts_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_comments" + }, + { + "nameExplicit": false, + "columns": [ + "parent" + ], + "schemaTo": "public", + "tableTo": "blog_comments", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "blog_comments_parent_blog_comments_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_comments" + }, + { + "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": [ + "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": [ + "deletedBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_comments_deletedBy_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" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_articles_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_articles" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "newsroom_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_articles_category_newsroom_categories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_bulletins", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_bulletins_translations_kgMZwrpzwwU7_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_bulletins_translations_rkkiMJQiOrEo_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_comments_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": false, + "columns": [ + "deletedBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_comments_deletedBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_comments_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": false, + "columns": [ + "article" + ], + "schemaTo": "public", + "tableTo": "newsroom_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_comments_article_newsroom_articles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": false, + "columns": [ + "parent" + ], + "schemaTo": "public", + "tableTo": "newsroom_comments", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_comments_parent_newsroom_comments_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_comments" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_events_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": false, + "columns": [ + "primaryTag" + ], + "schemaTo": "public", + "tableTo": "newsroom_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_events_primaryTag_newsroom_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "newsroom_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_events_category_newsroom_categories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_events_gallery_itemId_newsroom_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_events_gallery_relatedItemId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_events_related_events_itemId_newsroom_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_events_related_events_0xHNSLM3JAJj_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_events_sessions_itemId_newsroom_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_events_tags_itemId_newsroom_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_events_tags_relatedItemId_newsroom_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_events_translations_itemId_newsroom_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_events_translations_languageId_core_languages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_explainers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_explainers_categories_9dOmLv3k6xDX_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_explainers_categories_mnxKvproEUm5_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_explainers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_explainers_faq_itemId_newsroom_explainers_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_faq" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_explainers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_explainers_related_explainers_TFvIU3TogSGy_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_explainers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_explainers_related_explainers_74JhaXF5PsDV_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_explainers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_explainers_tags_itemId_newsroom_explainers_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_explainers_tags_relatedItemId_newsroom_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_explainers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_explainers_translations_hcPB0jdTm8MV_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_explainers_translations_qqggtF6jhcsZ_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_letters_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_letters_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": false, + "columns": [ + "desk" + ], + "schemaTo": "public", + "tableTo": "core_content_containers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_letters_desk_core_content_containers_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_letters" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_stories_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": false, + "columns": [ + "animation" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_stories_animation_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "newsroom_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_stories_category_newsroom_categories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_stories" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_stories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_stories_gallery_itemId_newsroom_stories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_stories_gallery_relatedItemId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_tags_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_tags" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "newsroom_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "newsroom_tags_category_newsroom_categories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_tags_related_tags_itemId_newsroom_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_tags_related_tags_relatedItemId_newsroom_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "newsroom_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "newsroom_tags_translations_itemId_newsroom_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "newsroom_tags_translations_languageId_core_languages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "payments_demo_purchases_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "payments_demo_purchases" + }, + { + "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": [ + "providerId", + "eventId" + ], + "nameExplicit": false, + "name": "core_payments_webhook_events_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_payments_webhook_events" + }, + { + "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", + "languageId" + ], + "nameExplicit": true, + "name": "newsroom_bulletins_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_bulletins_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_events_gallery_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_events_gallery" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_events_related_events_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_events_related_events" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_events_tags_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_events_tags" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "newsroom_events_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_events_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_explainers_categories_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_explainers_categories" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_explainers_related_explainers_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_explainers_related_explainers" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_explainers_tags_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_explainers_tags" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "newsroom_explainers_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_explainers_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_stories_gallery_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_stories_gallery" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "newsroom_tags_related_tags_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_tags_related_tags" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "newsroom_tags_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "newsroom_tags_translations" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_permissions_pkey", + "schema": "public", + "table": "core_admin_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_sessions_pkey", + "schema": "public", + "table": "core_admin_sessions", + "entityType": "pks" + }, + { + "columns": [ + "actionKey" + ], + "nameExplicit": false, + "name": "core_ai_action_settings_pkey", + "schema": "public", + "table": "core_ai_action_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_budget_periods_pkey", + "schema": "public", + "table": "core_ai_budget_periods", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_calls_pkey", + "schema": "public", + "table": "core_ai_calls", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_cost_adjustments_pkey", + "schema": "public", + "table": "core_ai_cost_adjustments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_reservations_pkey", + "schema": "public", + "table": "core_ai_reservations", + "entityType": "pks" + }, + { + "columns": [ + "roleId" + ], + "nameExplicit": false, + "name": "core_ai_role_policies_pkey", + "schema": "public", + "table": "core_ai_role_policies", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_runs_pkey", + "schema": "public", + "table": "core_ai_runs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_settings_pkey", + "schema": "public", + "table": "core_ai_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_translation_sources_pkey", + "schema": "public", + "table": "core_ai_translation_sources", + "entityType": "pks" + }, + { + "columns": [ + "userId" + ], + "nameExplicit": false, + "name": "core_ai_user_overrides_pkey", + "schema": "public", + "table": "core_ai_user_overrides", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_audit_logs_pkey", + "schema": "public", + "table": "core_audit_logs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_audit_settings_pkey", + "schema": "public", + "table": "core_audit_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_containers_pkey", + "schema": "public", + "table": "core_content_containers", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_documents_pkey", + "schema": "public", + "table": "core_content_documents", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_drafts_pkey", + "schema": "public", + "table": "core_content_drafts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_field_locks_pkey", + "schema": "public", + "table": "core_content_field_locks", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_submissions_pkey", + "schema": "public", + "table": "core_content_submissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_file_refs_pkey", + "schema": "public", + "table": "core_content_file_refs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_revisions_pkey", + "schema": "public", + "table": "core_content_revisions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_schedules_pkey", + "schema": "public", + "table": "core_content_schedules", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_slug_history_pkey", + "schema": "public", + "table": "core_content_slug_history", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_cron_pkey", + "schema": "public", + "table": "core_cron", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_dashboard_pkey", + "schema": "public", + "table": "core_admin_dashboard", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_pkey", + "schema": "public", + "table": "core_files", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_alt_pkey", + "schema": "public", + "table": "core_files_alt", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_alt_analysis_pkey", + "schema": "public", + "table": "core_files_alt_analysis", + "entityType": "pks" + }, + { + "columns": [ + "fileId" + ], + "nameExplicit": false, + "name": "core_files_alt_state_pkey", + "schema": "public", + "table": "core_files_alt_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_languages_pkey", + "schema": "public", + "table": "core_languages", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_languages_words_pkey", + "schema": "public", + "table": "core_languages_words", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_logs_pkey", + "schema": "public", + "table": "core_logs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderation_report_events_pkey", + "schema": "public", + "table": "core_moderation_report_events", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderation_report_reasons_pkey", + "schema": "public", + "table": "core_moderation_report_reasons", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderation_reports_pkey", + "schema": "public", + "table": "core_moderation_reports", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderators_permissions_pkey", + "schema": "public", + "table": "core_moderators_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_deliveries_pkey", + "schema": "public", + "table": "core_notification_deliveries", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_events_pkey", + "schema": "public", + "table": "core_notification_events", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "core_notification_settings_pkey", + "schema": "public", + "table": "core_notification_settings", + "entityType": "pks" + }, + { + "columns": [ + "userId" + ], + "nameExplicit": false, + "name": "core_notification_user_state_pkey", + "schema": "public", + "table": "core_notification_user_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notifications_pkey", + "schema": "public", + "table": "core_notifications", + "entityType": "pks" + }, + { + "columns": [ + "pageId" + ], + "nameExplicit": false, + "name": "core_page_layouts_pkey", + "schema": "public", + "table": "core_page_layouts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkey_challenges_pkey", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkeys_pkey", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_payments_pkey", + "schema": "public", + "table": "core_payments", + "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_sso_attempts_pkey", + "schema": "public", + "table": "core_sso_attempts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sso_pending_pkey", + "schema": "public", + "table": "core_sso_pending", + "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_comments_pkey", + "schema": "public", + "table": "blog_comments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_posts_pkey", + "schema": "public", + "table": "blog_posts", + "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" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_articles_pkey", + "schema": "public", + "table": "newsroom_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_bulletins_pkey", + "schema": "public", + "table": "newsroom_bulletins", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_categories_pkey", + "schema": "public", + "table": "newsroom_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_comments_pkey", + "schema": "public", + "table": "newsroom_comments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_events_pkey", + "schema": "public", + "table": "newsroom_events", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_events_sessions_pkey", + "schema": "public", + "table": "newsroom_events_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_explainers_pkey", + "schema": "public", + "table": "newsroom_explainers", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_explainers_faq_pkey", + "schema": "public", + "table": "newsroom_explainers_faq", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_letters_pkey", + "schema": "public", + "table": "newsroom_letters", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_pages_pkey", + "schema": "public", + "table": "newsroom_pages", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_stories_pkey", + "schema": "public", + "table": "newsroom_stories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "newsroom_tags_pkey", + "schema": "public", + "table": "newsroom_tags", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "payments_demo_purchases_pkey", + "schema": "public", + "table": "payments_demo_purchases", + "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": [ + "stateHash" + ], + "nullsNotDistinct": false, + "name": "core_sso_attempts_stateHash_key", + "schema": "public", + "table": "core_sso_attempts", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_sso_pending_tokenHash_key", + "schema": "public", + "table": "core_sso_pending", + "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": "\"amount\" > 0", + "name": "core_payments_amount_positive", + "entityType": "checks", + "schema": "public", + "table": "core_payments" + }, + { + "value": "\"intent\" IN ('link', 'import', 'sync', 'preview')", + "name": "core_users_sso_operations_intent_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "value": "\"field\" IN ('avatar', 'firstName', 'lastName')", + "name": "core_users_sso_profile_sources_field_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_profile_sources" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/apps/web/content/docs/content-engine/activity-logs-content-panel.webp b/apps/web/content/docs/content-engine/activity-logs-content-panel.webp new file mode 100644 index 0000000000000000000000000000000000000000..e6cd142525ae24d79d2af903cfd76d3c2cd2456a GIT binary patch literal 64814 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

[], > { 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 fcef09ab4a..21271b598f 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"; @@ -57,6 +58,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"; @@ -79,6 +81,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 { @@ -166,6 +169,11 @@ export interface EnvVariablesVitNode { adminSessionExpiresAt: Date | null; /** The AI model registry and action runner: see {@link AIModel}. */ ai: AIModel; + /** + * Records who did what to which resource in the central audit log (the + * AdminCP staff logs): see {@link AuditModel}. + */ + audit: AuditModel; /** Plugin-scoped JSON cache, Redis-backed when the API config has `redis`, a no-op otherwise. */ cache: CacheModel; /** @@ -176,6 +184,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; @@ -412,6 +422,8 @@ export const globalMiddleware = ({ const aiActionsMetadata = collectAiActions(plugins); + const auditActionsMetadata = collectAuditActions(plugins); + const eventsMetadata: EventListenerConfig[] = plugins.flatMap(plugin => (plugin.events ?? []).map(listener => ({ ...listener, @@ -615,6 +627,7 @@ export const globalMiddleware = ({ { ai, aiActions: aiActionsMetadata, + auditActions: auditActionsMetadata, i18n: i18nMetadata, metadata, email, @@ -695,6 +708,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..5ca8d9a5cb --- /dev/null +++ b/packages/vitnode/src/api/models/audit.ts @@ -0,0 +1,129 @@ +import type { Context } from "hono"; + +import type { + AuditArea, + AuditChanges, + AuditScope, + AuditTarget, +} from "@/lib/audit/const"; +import type { AuditDiffArgs } from "@/lib/audit/diff"; + +import { auditDiff } from "@/lib/audit/diff"; + +import type { AuditDatabase } from "../lib/audit/write"; + +import { writeAuditLog, writeAuditLogBestEffort } from "../lib/audit/write"; + +/** One operation to record with `c.get("audit").record()`. */ +export interface AuditRecordInput { + /** + * A stable identifier, lowercase words joined by dots, unique within the + * plugin: `topic.moved`, `category.updated`. It is stored as-is and is what + * the UI translates, so never rename one that has shipped. + */ + action: string; + /** + * Where the operation was performed. Defaults to `admin` on an AdminCP + * request, `public` for a signed-in user and `system` otherwise - pass + * `moderator` from moderation tools, which files the entry in the + * moderators' log instead of the admins'. + */ + area?: AuditArea; + /** Before/after values, usually from `c.get("audit").diff()`. Sensitive fields are always redacted. */ + changes?: AuditChanges | null; + /** + * Small, non-personal facts worth keeping (`{ fromForumId: 3 }`). Sanitized + * and size-capped; never pass request bodies, errors or secrets. + */ + metadata?: Record; + /** Defaults to the plugin of the route handling the request. */ + pluginId?: string; + /** + * The moderation scope the target lives in, as the moderation registry + * names it (`{ family: "forum", id: 4 }`). + */ + scope?: AuditScope | null; + target: AuditTarget; +} + +export interface AuditRecordOptions { + /** + * Log a failed write to the technical log instead of throwing. Only for + * operations that are not security-relevant; never with `tx`. + */ + bestEffort?: boolean; + /** + * The transaction of the change being recorded. The entry then commits and + * rolls back with it, and a failed write fails the change - use it for + * anything security-relevant. + */ + tx?: AuditDatabase; +} + +/** + * `c.get("audit")`: records who did what, when and to which resource in the + * central audit log shown in AdminCP -> Staff -> Administrators / Moderators -> Logs. + * Server-only. + * + * The actor, IP address and correlation id always come from the request (or + * from a trusted `withAuditContext` frame for background work), never from the + * input. Content Engine writes are recorded automatically - call this for + * your plugin's own operations. + * + * @example + * ```ts + * const note = await c.get("db").transaction(async tx => { + * const [created] = await tx.insert(site_notes).values(values).returning(); + * + * await c.get("audit").record( + * { + * action: "note.created", + * target: { type: "note", id: created.id, label: created.title }, + * changes: c.get("audit").diff({ + * before: null, + * after: created, + * include: ["title", "body"], + * }), + * }, + * { tx }, + * ); + * + * return created; + * }); + * ``` + */ +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/models/payments.ts b/packages/vitnode/src/api/models/payments.ts index cc3da33cc8..916b43c489 100644 --- a/packages/vitnode/src/api/models/payments.ts +++ b/packages/vitnode/src/api/models/payments.ts @@ -5,6 +5,8 @@ import { randomUUID } from "node:crypto"; import type { EnvVitNode } from "@/api/middlewares/global.middleware"; +import { AUDIT_SYSTEM_ACTOR } from "@/api/lib/audit/context"; +import { writeAuditLog } from "@/api/lib/audit/write"; import { isUniqueViolation } from "@/api/lib/db-errors"; import { assertPaymentAmount, @@ -25,6 +27,7 @@ import { core_payments, core_payments_webhook_events, } from "@/database/payments"; +import { AUDIT_CORE_PLUGIN_ID } from "@/lib/audit/const"; import { CONFIG } from "@/lib/config"; export type { PaymentStatus }; @@ -240,6 +243,47 @@ export class PaymentsModel { return after; } + /** + * Records a new attempt (`previous` is `null`) or a status move in the same + * transaction as the write. A move is the provider's doing, so it is the + * system's, whoever's request happened to notice it. + */ + private async audit( + tx: Tx, + payment: PaymentRow, + previous: null | PaymentStatus, + ) { + await writeAuditLog( + this.c, + { + action: `payment.${previous === null ? "created" : payment.status}`, + ...(previous === null + ? { area: "public" as const } + : { actor: AUDIT_SYSTEM_ACTOR, area: "system" as const }), + changes: + previous === null + ? null + : { status: { from: previous, to: payment.status } }, + metadata: { + amount: payment.amount, + currency: payment.currency, + plugin: payment.pluginId, + provider: payment.providerId, + reference: payment.reference, + ...(payment.userId === null ? {} : { customerId: payment.userId }), + ...(payment.errorCode ? { errorCode: payment.errorCode } : {}), + }, + pluginId: AUDIT_CORE_PLUGIN_ID, + target: { + id: payment.id, + label: payment.description || payment.reference, + type: "payment", + }, + }, + tx, + ); + } + private checkoutMismatch( payment: PaymentRow, state: PaymentCheckoutState, @@ -309,17 +353,22 @@ export class PaymentsModel { } private async expireUnstarted(row: PaymentRow, errorCode: string) { - const [expired] = await this.db - .update(core_payments) - .set({ status: "expired", errorCode }) - .where( - and( - eq(core_payments.id, row.id), - eq(core_payments.status, "pending"), - isNull(core_payments.checkoutId), - ), - ) - .returning(); + const expired = await this.db.transaction(async tx => { + const [updated] = await tx + .update(core_payments) + .set({ status: "expired", errorCode }) + .where( + and( + eq(core_payments.id, row.id), + eq(core_payments.status, "pending"), + isNull(core_payments.checkoutId), + ), + ) + .returning(); + if (updated) await this.audit(tx, updated, "pending"); + + return updated; + }); if (expired) await this.emitStatusChange(expired); } @@ -487,17 +536,22 @@ export class PaymentsModel { await this.logProviderError(provider, "createCheckout", error); if (error instanceof PaymentProviderError && error.kind === "rejected") { - const [failed] = await this.db - .update(core_payments) - .set({ status: "failed", errorCode: error.code.slice(0, 64) }) - .where( - and( - eq(core_payments.id, row.id), - eq(core_payments.status, "pending"), - isNull(core_payments.checkoutId), - ), - ) - .returning(); + const failed = await this.db.transaction(async tx => { + const [updated] = await tx + .update(core_payments) + .set({ status: "failed", errorCode: error.code.slice(0, 64) }) + .where( + and( + eq(core_payments.id, row.id), + eq(core_payments.status, "pending"), + isNull(core_payments.checkoutId), + ), + ) + .returning(); + if (updated) await this.audit(tx, updated, "pending"); + + return updated; + }); if (failed) await this.emitStatusChange(failed); throw new PaymentError("provider_rejected", error.message); @@ -602,9 +656,10 @@ export class PaymentsModel { ) .returning(); - if (!moves) return null; + if (!moves || !updated) return null; + await this.audit(tx, updated, payment.status); - return updated ?? null; + return updated; } /** @@ -668,22 +723,29 @@ export class PaymentsModel { const id = randomUUID(); let row: PaymentRow | undefined; try { - [row] = await this.db - .insert(core_payments) - .values({ - id, - pluginId: this.pluginId, - reference, - userId: input.userId, - providerId: provider.id, - providerType: provider.type, - amount: input.amount, - currency, - description, - status: "pending", - expiresAt: new Date(now + this.c.get("core").payments.checkoutTtlMs), - }) - .returning(); + row = await this.db.transaction(async tx => { + const [inserted] = await tx + .insert(core_payments) + .values({ + id, + pluginId: this.pluginId, + reference, + userId: input.userId, + providerId: provider.id, + providerType: provider.type, + amount: input.amount, + currency, + description, + status: "pending", + expiresAt: new Date( + now + this.c.get("core").payments.checkoutTtlMs, + ), + }) + .returning(); + if (inserted) await this.audit(tx, inserted, null); + + return inserted; + }); } catch (error) { if (isUniqueViolation(error)) { throw new PaymentError("payment_in_progress"); diff --git a/packages/vitnode/src/api/modules/admin/activity-logs/activity-logs.admin.module.ts b/packages/vitnode/src/api/modules/admin/activity-logs/activity-logs.admin.module.ts new file mode 100644 index 0000000000..575b625ec6 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/activity-logs/activity-logs.admin.module.ts @@ -0,0 +1,25 @@ +import { buildModule } from "@/api/builders"; + +import { activityLogDetailAdminRoute } from "./routes/detail.route"; +import { activityLogEntityAdminRoute } from "./routes/entity.route"; +import { exportActivityLogsAdminRoute } from "./routes/export.route"; +import { activityLogFiltersAdminRoute } from "./routes/filters.route"; +import { listActivityLogsAdminRoute } from "./routes/list.route"; +import { + activityLogSettingsAdminRoute, + updateActivityLogSettingsAdminRoute, +} from "./routes/settings.route"; + +export const activityLogsAdminModule = buildModule({ + name: "activity-logs", + // The fixed paths first, so `/{id}` never shadows them. + routes: [ + listActivityLogsAdminRoute, + activityLogFiltersAdminRoute, + activityLogEntityAdminRoute, + exportActivityLogsAdminRoute, + activityLogSettingsAdminRoute, + updateActivityLogSettingsAdminRoute, + activityLogDetailAdminRoute, + ], +}); diff --git a/packages/vitnode/src/api/modules/admin/activity-logs/routes/activity-logs.routes.test.ts b/packages/vitnode/src/api/modules/admin/activity-logs/routes/activity-logs.routes.test.ts new file mode 100644 index 0000000000..f083d378e9 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/activity-logs/routes/activity-logs.routes.test.ts @@ -0,0 +1,334 @@ +import type { Context } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { describe, expect, it } from "vitest"; + +import type { PermissionsStaffArgs } from "@/api/lib/permission-staff"; + +import { core_audit_logs, core_audit_settings } from "@/database/audit"; +import { core_roles } from "@/database/roles"; +import { core_users } from "@/database/users"; +import { createTestCache } from "@/tests/cache"; +import { testCategoryContentType } from "@/tests/content-fixtures"; +import { createMemoryDb } from "@/tests/memory-db"; +import { grantStaffPermissions } from "@/tests/staff-permissions"; + +import { activityLogDetailAdminRoute } from "./detail.route"; +import { activityLogEntityAdminRoute } from "./entity.route"; +import { exportActivityLogsAdminRoute } from "./export.route"; +import { listActivityLogsAdminRoute } from "./list.route"; +import { updateActivityLogSettingsAdminRoute } from "./settings.route"; + +const BLOG = "@vitnode/blog"; +const MEMBER_ROLE = 3; + +const log = (permission: string): PermissionsStaffArgs => ({ + module: "activity_logs", + permission, + plugin: "@vitnode/core", +}); + +const ADMINS = { + editor: [ + { + module: testCategoryContentType.permissionModule, + permission: "can_view", + plugin: BLOG, + }, + ], + exporter: [log("can_export")], + nobody: [ + { module: "users", permission: "can_view", plugin: "@vitnode/core" }, + ], + retention: [log("can_manage_retention")], + sensitive: [log("can_view"), log("can_view_sensitive"), log("can_export")], + viewer: [log("can_view")], +} satisfies Record; + +type Admin = keyof typeof ADMINS; + +const ADMIN_IDS = Object.fromEntries( + Object.keys(ADMINS).map((name, index) => [name, index + 1]), +) as Record; + +const ENTRIES = [ + { + action: "role.updated", + actorId: 1, + actorName: "Editor", + actorType: "staff", + area: "admin", + correlationId: "corr-1", + createdAt: new Date("2026-10-01T00:00:00.000Z"), + id: 1, + ipAddress: "203.0.113.7", + metadata: {}, + pluginId: "@vitnode/core", + targetId: "5", + targetLabel: '=HYPERLINK("http://evil.test","Click")', + targetType: "role", + }, + { + action: "content.updated", + actorId: 1, + actorName: "Editor", + actorType: "staff", + area: "admin", + changes: { title: { from: "Old", to: "New" } }, + contentTypeId: testCategoryContentType.id, + createdAt: new Date("2026-10-02T00:00:00.000Z"), + id: 2, + ipAddress: "203.0.113.7", + metadata: {}, + pluginId: BLOG, + targetId: "7", + targetLabel: "News", + targetType: testCategoryContentType.id, + }, + { + action: "user.banned", + actorId: 1, + actorName: "Editor", + actorType: "staff", + area: "moderator", + createdAt: new Date("2026-10-03T00:00:00.000Z"), + id: 3, + metadata: {}, + pluginId: "@vitnode/core", + targetId: "9", + targetLabel: "Spammer", + targetType: "user", + }, + { + action: "audit_logs.pruned", + actorId: null, + actorName: null, + actorType: "system", + area: "system", + createdAt: new Date("2026-10-04T00:00:00.000Z"), + id: 4, + metadata: {}, + pluginId: "@vitnode/core", + targetType: "audit_logs", + }, +]; + +const harness = async () => { + const cache = createTestCache(); + for (const [name, permissions] of Object.entries(ADMINS)) { + await grantStaffPermissions(cache, { + permissions, + userId: ADMIN_IDS[name as Admin], + }); + } + const memory = createMemoryDb([ + [ + core_users, + Object.entries(ADMIN_IDS).map(([name, id]) => ({ + id, + name, + nameCode: name, + roleId: MEMBER_ROLE, + })), + ], + [core_roles, [{ color: null, id: MEMBER_ROLE, prefix: null }]], + [core_audit_logs, ENTRIES], + [core_audit_settings, []], + ]); + + const app = new OpenAPIHono(); + app.use("*", async (c, next) => { + const name = c.req.header("x-admin") as Admin; + c.set("admin", { + user: { id: ADMIN_IDS[name], name, roleId: MEMBER_ROLE }, + } as unknown as Context["var"]["admin"]); + c.set("cache", cache); + c.set("db", memory.db as unknown as Context["var"]["db"]); + c.set("core", { + contentModels: [ + { model: { definition: testCategoryContentType }, pluginId: BLOG }, + ], + } as unknown as Context["var"]["core"]); + await next(); + }); + for (const built of [ + exportActivityLogsAdminRoute, + activityLogEntityAdminRoute, + updateActivityLogSettingsAdminRoute, + activityLogDetailAdminRoute, + listActivityLogsAdminRoute, + ]) { + app.openapi(built.route, built.handler); + } + + const request = async (admin: Admin, path: string, init: RequestInit = {}) => + await app.request(path, { + ...init, + headers: { + "content-type": "application/json", + "x-admin": admin, + ...init.headers, + }, + }); + + const audits = (action: string) => + memory.rows(core_audit_logs).filter(row => row.action === action); + + return { audits, memory, request }; +}; + +const ENTITY = `/entity?pluginId=${encodeURIComponent(BLOG)}&targetType=${testCategoryContentType.id}&targetId=7`; + +describe("Activity Logs admin routes", () => { + it.each([ + ["the list", "/", "viewer", 200], + ["the list", "/", "nobody", 403], + [ + "an entity history with the content type's can_view only", + ENTITY, + "editor", + 200, + ], + ["an entity history", ENTITY, "nobody", 403], + ["the export", "/export", "exporter", 200], + ["the export", "/export", "viewer", 403], + ] as const)( + "answers %s (%s) for %s with %i", + async (_label, path, admin, status) => { + const h = await harness(); + + expect((await h.request(admin, path)).status).toBe(status); + }, + ); + + it("lists entries newest first", async () => { + const h = await harness(); + + const res = await h.request("viewer", "/"); + const body = (await res.json()) as { + edges: { id: number }[]; + pageInfo: { hasNextPage: boolean }; + }; + + expect(body.edges.map(edge => edge.id)).toEqual([4, 3, 2, 1]); + expect(body.pageInfo.hasNextPage).toBe(false); + }); + + it.each([ + ["?staff=admin", [4, 2, 1]], + ["?staff=moderator", [3]], + ] as const)("filters the list by staff log (%s)", async (query, ids) => { + const h = await harness(); + + const res = await h.request("viewer", `/${query}`); + const body = (await res.json()) as { edges: { id: number }[] }; + + expect(body.edges.map(edge => edge.id)).toEqual(ids); + }); + + it("shows an entity's history with its recorded changes", async () => { + const h = await harness(); + + const res = await h.request("editor", ENTITY); + + expect(await res.json()).toMatchObject({ + edges: [{ changes: { title: { from: "Old", to: "New" } }, id: 2 }], + }); + }); + + it.each([ + ["hides", "viewer", { correlationId: null, ipAddress: null }], + [ + "shows", + "sensitive", + { correlationId: "corr-1", ipAddress: "203.0.113.7" }, + ], + ] as const)( + "%s the IP address and correlation id in the detail for %s", + async (_label, admin, expected) => { + const h = await harness(); + + const res = await h.request(admin, "/1"); + + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ id: 1, ...expected }); + }, + ); + + describe("export", () => { + it("escapes formulas, omits IP addresses without can_view_sensitive and records itself", async () => { + const h = await harness(); + + const res = await h.request("exporter", "/export"); + const csv = await res.text(); + const [header] = csv.split("\r\n"); + + expect(res.headers.get("content-type")).toContain("text/csv"); + expect(header).not.toContain("ipAddress"); + expect(csv).not.toContain("203.0.113.7"); + expect(csv).toContain(`"'=HYPERLINK(""http://evil.test"",""Click"")"`); + expect(h.audits("audit_logs.exported")).toEqual([ + expect.objectContaining({ + actorId: ADMIN_IDS.exporter, + metadata: expect.objectContaining({ rows: 4 }), + }), + ]); + }); + + it("includes IP addresses with can_view_sensitive", async () => { + const h = await harness(); + + const csv = await (await h.request("sensitive", "/export")).text(); + + expect(csv.split("\r\n")[0]).toContain('"ipAddress"'); + expect(csv).toContain("203.0.113.7"); + }); + }); + + describe("PUT /settings", () => { + const put = async ( + h: Awaited>, + admin: Admin, + retentionDays: number, + ) => + await h.request(admin, "/settings", { + body: JSON.stringify({ retentionDays }), + method: "PUT", + }); + + it("saves an offered retention period and records the change", async () => { + const h = await harness(); + + const res = await put(h, "retention", 90); + + expect(res.status).toBe(200); + expect(h.memory.rows(core_audit_settings)).toEqual([ + expect.objectContaining({ id: 1, retentionDays: 90 }), + ]); + expect(h.audits("audit_settings.updated")).toEqual([ + expect.objectContaining({ + changes: { retentionDays: { from: 365, to: 90 } }, + }), + ]); + }); + + it.each([ + ["a period that is not offered", "retention", 45, 400], + ["an admin without can_manage_retention", "viewer", 90, 403], + ] as const)("refuses %s", async (_label, admin, days, status) => { + const h = await harness(); + + expect((await put(h, admin, days)).status).toBe(status); + expect(h.memory.rows(core_audit_settings)).toEqual([]); + expect(h.audits("audit_settings.updated")).toEqual([]); + }); + + it("records nothing when the period does not change", async () => { + const h = await harness(); + + await put(h, "retention", 365); + + expect(h.audits("audit_settings.updated")).toEqual([]); + }); + }); +}); diff --git a/packages/vitnode/src/api/modules/admin/activity-logs/routes/detail.route.ts b/packages/vitnode/src/api/modules/admin/activity-logs/routes/detail.route.ts new file mode 100644 index 0000000000..f50947bad5 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/activity-logs/routes/detail.route.ts @@ -0,0 +1,43 @@ +import { z } from "@hono/zod-openapi"; + +import { buildRoute } from "@/api/builders"; +import { AUDIT_PERMISSIONS, canAdminAudit } from "@/api/lib/audit/access"; +import { auditDetailOf } from "@/api/lib/audit/detail"; +import { findAuditLog, zodAuditDetail } from "@/api/lib/audit/query"; + +export const activityLogDetailAdminRoute = buildRoute({ + adminStaffPermission: { module: "activity_logs", permission: "can_view" }, + route: { + method: "get", + path: "/{id}", + description: + "One Activity Logs entry with its changes, metadata and the content revision it points at", + request: { + params: z.object({ + id: z.coerce.number().int().positive().openapi({ example: 1 }), + }), + }, + responses: { + 200: { + content: { "application/json": { schema: zodAuditDetail } }, + description: "The entry", + }, + 404: { description: "No such entry" }, + }, + }, + handler: async c => { + const { id } = c.req.valid("param"); + const entry = await findAuditLog(c, id); + if (!entry) return c.json({ error: "Not found" }, 404); + + return c.json( + await auditDetailOf(c, entry, { + canViewSensitive: await canAdminAudit( + c, + AUDIT_PERMISSIONS.viewSensitive, + ), + }), + 200, + ); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/activity-logs/routes/entity.route.ts b/packages/vitnode/src/api/modules/admin/activity-logs/routes/entity.route.ts new file mode 100644 index 0000000000..a98aaa4575 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/activity-logs/routes/entity.route.ts @@ -0,0 +1,73 @@ +import type { Context } from "hono"; + +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 { viewableContentType } from "@/api/lib/audit/detail"; +import { + listAuditLogsWithDetails, + zodAuditEntityPage, +} from "@/api/lib/audit/query"; +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: Context, + { pluginId, targetType }: { pluginId: string; targetType: string }, +): Promise => { + if (await canAdminAudit(c, AUDIT_PERMISSIONS.view)) return true; + + return (await viewableContentType(c, targetType))?.pluginId === pluginId; +}; + +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, { + 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..94f74b7897 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/activity-logs/routes/export.route.ts @@ -0,0 +1,82 @@ +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, { + 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..1c4fb0ab21 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/activity-logs/routes/list.route.ts @@ -0,0 +1,29 @@ +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, { cursor, filters, first }), 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/activity-logs/routes/settings.route.ts b/packages/vitnode/src/api/modules/admin/activity-logs/routes/settings.route.ts new file mode 100644 index 0000000000..11b71ec105 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/activity-logs/routes/settings.route.ts @@ -0,0 +1,85 @@ +import { z } from "@hono/zod-openapi"; + +import { buildRoute } from "@/api/builders"; +import { loadAuditSettings, saveAuditSettings } from "@/api/lib/audit/settings"; +import { writeAuditLog } from "@/api/lib/audit/write"; +import { AUDIT_RETENTION_DAYS_OPTIONS } from "@/lib/audit/const"; +import { auditDiff } from "@/lib/audit/diff"; + +const zodSettings = z.object({ + retentionDays: z + .number() + .int() + .refine( + value => + (AUDIT_RETENTION_DAYS_OPTIONS as readonly number[]).includes(value), + { message: "Choose one of the offered retention periods." }, + ) + .openapi({ example: 365 }), +}); + +export const activityLogSettingsAdminRoute = buildRoute({ + adminStaffPermission: { module: "activity_logs", permission: "can_view" }, + route: { + method: "get", + path: "/settings", + description: "How long Activity Logs entries are kept", + responses: { + 200: { + content: { "application/json": { schema: zodSettings } }, + description: "The settings", + }, + }, + }, + handler: async c => c.json(await loadAuditSettings(c.get("db")), 200), +}); + +export const updateActivityLogSettingsAdminRoute = buildRoute({ + adminStaffPermission: { + module: "activity_logs", + permission: "can_manage_retention", + }, + route: { + method: "put", + path: "/settings", + description: + "Change how long Activity Logs entries are kept. The daily retention job deletes what is older", + request: { + body: { + required: true, + content: { "application/json": { schema: zodSettings } }, + }, + }, + responses: { + 200: { + content: { "application/json": { schema: zodSettings } }, + description: "Saved", + }, + }, + }, + handler: async c => { + const body = c.req.valid("json"); + await c.get("db").transaction(async tx => { + const before = await loadAuditSettings(tx); + await saveAuditSettings(tx, body); + const changes = auditDiff({ after: body, before }); + if (changes) { + await writeAuditLog( + c, + { + action: "audit_settings.updated", + changes, + target: { + id: "activity_logs", + label: "Activity Logs", + type: "settings", + }, + }, + tx, + ); + } + }); + + return c.json(body, 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/admin.module.ts b/packages/vitnode/src/api/modules/admin/admin.module.ts index 914e3579b3..0c25a69cdd 100644 --- a/packages/vitnode/src/api/modules/admin/admin.module.ts +++ b/packages/vitnode/src/api/modules/admin/admin.module.ts @@ -1,5 +1,6 @@ import { buildModule } from "@/api/builders"; +import { activityLogsAdminModule } from "./activity-logs/activity-logs.admin.module"; import { advancedAdminModule } from "./advanced/advanced.admin.module"; import { aiAdminModule } from "./ai/ai.admin.module"; import { dashboardAdminModule } from "./dashboard/dashboard.admin.module"; @@ -18,6 +19,7 @@ export const adminModule = buildModule({ name: "admin", routes: [sessionAdminRoute, sendNotificationRoute], modules: [ + activityLogsAdminModule, aiAdminModule, usersAdminModule, rolesAdminModule, diff --git a/packages/vitnode/src/api/modules/admin/advanced/cron/routes/run.route.ts b/packages/vitnode/src/api/modules/admin/advanced/cron/routes/run.route.ts index aaf5f40f74..31575ce005 100644 --- a/packages/vitnode/src/api/modules/admin/advanced/cron/routes/run.route.ts +++ b/packages/vitnode/src/api/modules/admin/advanced/cron/routes/run.route.ts @@ -4,6 +4,8 @@ import { z } from "zod"; import type { CronJobConfig } from "@/api/lib/cron"; import { buildRoute } from "@/api/builders"; +import { AUDIT_SYSTEM_ACTOR, withAuditContext } from "@/api/lib/audit/context"; +import { writeAuditLog } from "@/api/lib/audit/write"; import { core_cron } from "@/database/cron"; import { getNextCronRunDate } from "@/lib/api/get-next-cron-run-date"; @@ -86,7 +88,31 @@ export const runCronRoute = buildRoute({ const now = new Date(); try { - await job.handler(c); + // The job itself runs as the system - what it writes was not done by + // hand - while the admin who started it is named on its entries and + // recorded for starting it. + await withAuditContext( + c, + { + actor: AUDIT_SYSTEM_ACTOR, + area: "system", + metadata: { + cronJob: job.name, + trigger: "cron_manual", + triggeredByUserId: c.get("admin")?.user.id ?? null, + }, + }, + async () => { + await job.handler(c); + }, + ); + + await writeAuditLog(c, { + action: "cron.run", + metadata: { module: job.module }, + pluginId: job.pluginId, + target: { id: dbCron.id, label: job.name, type: "cron_job" }, + }); await db .update(core_cron) diff --git a/packages/vitnode/src/api/modules/admin/ai/routes/access.route.ts b/packages/vitnode/src/api/modules/admin/ai/routes/access.route.ts index 53892cc97d..f2c2331865 100644 --- a/packages/vitnode/src/api/modules/admin/ai/routes/access.route.ts +++ b/packages/vitnode/src/api/modules/admin/ai/routes/access.route.ts @@ -3,6 +3,8 @@ import { z } from "zod"; import { buildRoute } from "@/api/builders"; import { formatDecimal, parseDecimal } from "@/api/lib/ai/decimal"; +import { roleAuditLabel, userAuditLabel } from "@/api/lib/audit/labels"; +import { writeAuditLog } from "@/api/lib/audit/write"; import { core_ai_role_permissions, core_ai_role_policies, @@ -278,6 +280,28 @@ export const updateAiRoleAccessAdminRoute = buildRoute({ ], }); } + + await writeAuditLog( + c, + { + action: "ai_access.role_updated", + metadata: { + grants: grants.map(grant => ({ + dailyLimit: grant.dailyLimit, + granted: grant.granted, + permission: grant.permission, + })), + monthlyPoints, + unlimited, + }, + target: { + id: roleId, + label: await roleAuditLabel(c, roleId), + type: "role", + }, + }, + tx, + ); }); return c.json({ ok: true as const }, 200); @@ -317,14 +341,25 @@ export const updateAiUserOverrideAdminRoute = buildRoute({ }, handler: async c => { const { userId, ...values } = c.req.valid("json"); - await c - .get("db") - .insert(core_ai_user_overrides) - .values({ userId, ...values }) - .onConflictDoUpdate({ - set: values, - target: core_ai_user_overrides.userId, - }); + const label = await userAuditLabel(c, userId); + await c.get("db").transaction(async tx => { + await tx + .insert(core_ai_user_overrides) + .values({ userId, ...values }) + .onConflictDoUpdate({ + set: values, + target: core_ai_user_overrides.userId, + }); + await writeAuditLog( + c, + { + action: "ai_access.user_override_saved", + metadata: values, + target: { id: userId, label, type: "user" }, + }, + tx, + ); + }); return c.json({ ok: true as const }, 200); }, @@ -348,10 +383,23 @@ export const deleteAiUserOverrideAdminRoute = buildRoute({ }, handler: async c => { const { userId } = c.req.valid("query"); - await c - .get("db") - .delete(core_ai_user_overrides) - .where(eq(core_ai_user_overrides.userId, userId)); + const label = await userAuditLabel(c, userId); + await c.get("db").transaction(async tx => { + const removed = await tx + .delete(core_ai_user_overrides) + .where(eq(core_ai_user_overrides.userId, userId)) + .returning({ userId: core_ai_user_overrides.userId }); + if (removed.length === 0) return; + + await writeAuditLog( + c, + { + action: "ai_access.user_override_removed", + target: { id: userId, label, type: "user" }, + }, + tx, + ); + }); return c.json({ ok: true as const }, 200); }, diff --git a/packages/vitnode/src/api/modules/admin/ai/routes/update-settings.route.ts b/packages/vitnode/src/api/modules/admin/ai/routes/update-settings.route.ts index d872929b96..0cb434ef0f 100644 --- a/packages/vitnode/src/api/modules/admin/ai/routes/update-settings.route.ts +++ b/packages/vitnode/src/api/modules/admin/ai/routes/update-settings.route.ts @@ -1,8 +1,12 @@ +import { eq } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; import { z } from "zod"; import { buildRoute } from "@/api/builders"; import { saveAiSettings } from "@/api/lib/ai/settings-store"; +import { writeAuditLog } from "@/api/lib/audit/write"; +import { core_ai_settings } from "@/database/ai"; +import { auditDiff } from "@/lib/audit/diff"; import { zodAiSettings } from "../schemas"; @@ -37,7 +41,31 @@ export const updateAiSettingsAdminRoute = buildRoute({ }); } - await saveAiSettings(c.get("db"), body); + await c.get("db").transaction(async tx => { + const [before] = await tx + .select() + .from(core_ai_settings) + .where(eq(core_ai_settings.id, 1)) + .limit(1); + await saveAiSettings(tx, body); + + const changes = auditDiff({ + after: body, + before: before ?? null, + include: Object.keys(body) as (keyof typeof body)[], + }); + if (changes) { + await writeAuditLog( + c, + { + action: "settings.updated", + changes, + target: { id: "ai", label: "AI", type: "settings" }, + }, + tx, + ); + } + }); return c.json({ ok: true as const }, 200); }, diff --git a/packages/vitnode/src/api/modules/admin/notifications/routes/danger.route.ts b/packages/vitnode/src/api/modules/admin/notifications/routes/danger.route.ts index f0f1d3383f..213628f9c3 100644 --- a/packages/vitnode/src/api/modules/admin/notifications/routes/danger.route.ts +++ b/packages/vitnode/src/api/modules/admin/notifications/routes/danger.route.ts @@ -1,6 +1,9 @@ +import type { Context } from "hono"; + import { z } from "zod"; import { buildRoute } from "@/api/builders"; +import { writeAuditLogBestEffort } from "@/api/lib/audit/write"; import { cancelQueuedNotificationEmails, deleteAllNotifications, @@ -10,6 +13,14 @@ import { resumeNotifications, } from "@/api/models/notifications/danger"; +/** The danger-zone operations act on every member at once, so each is recorded. */ +const auditDanger = async (c: Context, operation: string): Promise => { + await writeAuditLogBestEffort(c, { + action: `notifications.${operation}`, + target: { label: "Notifications", type: "settings", id: "notifications" }, + }); +}; + const canManageNotifications = { module: "notifications", permission: "can_manage", @@ -32,6 +43,7 @@ export const pauseNotificationsRoute = buildRoute({ }, handler: async c => { await pauseNotifications(c); + await auditDanger(c, "paused"); return c.json({ success: true }); }, @@ -49,7 +61,12 @@ export const resumeNotificationsRoute = buildRoute({ "Resumed", ), }, - handler: async c => c.json(await resumeNotifications(c)), + handler: async c => { + const result = await resumeNotifications(c); + await auditDanger(c, "resumed"); + + return c.json(result); + }, }); export const cancelQueuedNotificationEmailsRoute = buildRoute({ @@ -64,7 +81,12 @@ export const cancelQueuedNotificationEmailsRoute = buildRoute({ "Cancelled", ), }, - handler: async c => c.json(await cancelQueuedNotificationEmails(c)), + handler: async c => { + const result = await cancelQueuedNotificationEmails(c); + await auditDanger(c, "emails_cancelled"); + + return c.json(result); + }, }); export const markEverythingReadRoute = buildRoute({ @@ -79,7 +101,12 @@ export const markEverythingReadRoute = buildRoute({ "Marked as read", ), }, - handler: async c => c.json(await markEverythingRead(c)), + handler: async c => { + const result = await markEverythingRead(c); + await auditDanger(c, "all_marked_read"); + + return c.json(result); + }, }); export const deleteAllNotificationsRoute = buildRoute({ @@ -94,7 +121,12 @@ export const deleteAllNotificationsRoute = buildRoute({ "Deleted", ), }, - handler: async c => c.json(await deleteAllNotifications(c)), + handler: async c => { + const result = await deleteAllNotifications(c); + await auditDanger(c, "all_deleted"); + + return c.json(result); + }, }); export const resetMemberNotificationPreferencesRoute = buildRoute({ @@ -106,5 +138,10 @@ export const resetMemberNotificationPreferencesRoute = buildRoute({ path: "/members/reset-preferences", responses: dangerResponses(z.object({ members: z.number() }), "Reset"), }, - handler: async c => c.json(await resetMemberNotificationPreferences(c)), + handler: async c => { + const result = await resetMemberNotificationPreferences(c); + await auditDanger(c, "member_preferences_reset"); + + return c.json(result); + }, }); diff --git a/packages/vitnode/src/api/modules/admin/report-reasons/routes/report-reasons.routes.ts b/packages/vitnode/src/api/modules/admin/report-reasons/routes/report-reasons.routes.ts index 96dc39595e..a0e88c3609 100644 --- a/packages/vitnode/src/api/modules/admin/report-reasons/routes/report-reasons.routes.ts +++ b/packages/vitnode/src/api/modules/admin/report-reasons/routes/report-reasons.routes.ts @@ -5,6 +5,8 @@ import { and, count, eq, inArray, max, sql } from "drizzle-orm"; import { randomBytes } from "node:crypto"; import { buildRoute } from "@/api/builders"; +import { multiLangAuditLabel } from "@/api/lib/audit/labels"; +import { writeAuditLog } from "@/api/lib/audit/write"; import { listReportReasons, REPORT_REASON_WORDS, @@ -14,6 +16,7 @@ import { saveLanguageWords } from "@/api/lib/save-language-words"; import { errorSchema, json } from "@/api/modules/moderation/lib/schema"; import { core_languages_words } from "@/database/languages"; import { core_moderation_report_reasons } from "@/database/moderation"; +import { auditDiff } from "@/lib/audit/diff"; import { MODERATION_LIMITS } from "@/lib/moderation/const"; const titleSchema = z @@ -80,6 +83,7 @@ const findReasonRow = async (c: Context, id: number) => { .select({ code: core_moderation_report_reasons.code, id: core_moderation_report_reasons.id, + requiresDetails: core_moderation_report_reasons.requiresDetails, }) .from(core_moderation_report_reasons) .where(eq(core_moderation_report_reasons.id, id)) @@ -149,6 +153,26 @@ export const createReportReasonAdminRoute = buildRoute({ }) .returning({ id: core_moderation_report_reasons.id }); + await writeAuditLog( + c, + { + action: "report_reason.created", + changes: auditDiff({ + after: { + requiresDetails: body.requiresDetails, + title: multiLangAuditLabel(body.title), + }, + before: null, + }), + target: { + id: row.id, + label: multiLangAuditLabel(body.title), + type: "report_reason", + }, + }, + tx, + ); + return row; }); if (!created) return c.json({ error: "limit_reached" }, 409); @@ -188,13 +212,37 @@ export const updateReportReasonAdminRoute = buildRoute({ const row = await findReasonRow(c, id); if (!row) return c.json({ error: "not_found" }, 404); - if (body.requiresDetails !== undefined) { - await c - .get("db") - .update(core_moderation_report_reasons) - .set({ requiresDetails: body.requiresDetails }) - .where(eq(core_moderation_report_reasons.id, id)); - } + await c.get("db").transaction(async tx => { + if (body.requiresDetails !== undefined) { + await tx + .update(core_moderation_report_reasons) + .set({ requiresDetails: body.requiresDetails }) + .where(eq(core_moderation_report_reasons.id, id)); + } + await writeAuditLog( + c, + { + action: "report_reason.updated", + changes: auditDiff({ + after: { + requiresDetails: body.requiresDetails ?? row.requiresDetails, + ...(body.title === undefined + ? {} + : { title: multiLangAuditLabel(body.title) }), + }, + before: { requiresDetails: row.requiresDetails }, + }), + metadata: + body.description === undefined ? {} : { descriptionChanged: true }, + target: { + id, + label: multiLangAuditLabel(body.title) ?? row.code, + type: "report_reason", + }, + }, + tx, + ); + }); await saveReasonWords(c, id, body); await c.get("events").emit("moderation.report_reason.updated", { code: row.code, @@ -244,6 +292,14 @@ export const deleteReportReasonAdminRoute = buildRoute({ await tx .delete(core_moderation_report_reasons) .where(eq(core_moderation_report_reasons.id, id)); + await writeAuditLog( + c, + { + action: "report_reason.deleted", + target: { id, label: row.code, type: "report_reason" }, + }, + tx, + ); return true; }); @@ -303,10 +359,21 @@ export const reorderReportReasonsAdminRoute = buildRoute({ ), sql` `, ); - await db - .update(core_moderation_report_reasons) - .set({ position: sql`case ${positionById} end` }) - .where(inArray(core_moderation_report_reasons.id, ids)); + await db.transaction(async tx => { + await tx + .update(core_moderation_report_reasons) + .set({ position: sql`case ${positionById} end` }) + .where(inArray(core_moderation_report_reasons.id, ids)); + await writeAuditLog( + c, + { + action: "report_reason.reordered", + metadata: { order: ids }, + target: { type: "report_reason" }, + }, + tx, + ); + }); await c .get("events") .emit("moderation.report_reason.reordered", { reasonIds: ids }); diff --git a/packages/vitnode/src/api/modules/admin/roles/routes/create.route.test.ts b/packages/vitnode/src/api/modules/admin/roles/routes/create.route.test.ts new file mode 100644 index 0000000000..b6cce4489b --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/roles/routes/create.route.test.ts @@ -0,0 +1,114 @@ +import type { Table } from "drizzle-orm"; +import type { Context } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { describe, expect, it, vi } from "vitest"; + +import { core_audit_logs } from "@/database/audit"; +import { core_languages_words } from "@/database/languages"; +import { core_roles } from "@/database/roles"; +import { core_users } from "@/database/users"; +import { createTestCache } from "@/tests/cache"; +import { createMemoryDb } from "@/tests/memory-db"; +import { + grantStaffPermissions, + ROOT_STAFF_PERMISSIONS, +} from "@/tests/staff-permissions"; + +import { createRoleAdminRoute } from "./create.route"; + +const CALLER = { id: 1, name: "Root", roleId: 3 }; + +type MemoryDb = ReturnType["db"]; + +/** The memory db, with every audit insert inside a transaction failing. */ +const failingAuditDb = (db: MemoryDb): MemoryDb => ({ + ...db, + transaction: async run => + await db.transaction(async tx => { + const inner = tx as MemoryDb; + + return await run({ + ...inner, + insert: (table: Table) => { + if (table === core_audit_logs) throw new Error("audit write failed"); + + return inner.insert(table); + }, + }); + }), +}); + +const harness = async ({ failAudit = false } = {}) => { + const cache = createTestCache(); + await grantStaffPermissions(cache, { + permissions: ROOT_STAFF_PERMISSIONS, + userId: CALLER.id, + }); + const memory = createMemoryDb([ + [core_roles, []], + [core_users, [CALLER]], + [core_languages_words, []], + ]); + const db = failAudit ? failingAuditDb(memory.db) : memory.db; + + const app = new OpenAPIHono(); + app.use("*", async (c, next) => { + c.set("admin", { user: CALLER } as unknown as Context["var"]["admin"]); + c.set("cache", cache); + c.set("db", db as unknown as Context["var"]["db"]); + c.set("events", { + emit: vi.fn(async () => Promise.resolve()), + } as unknown as Context["var"]["events"]); + await next(); + }); + app.openapi(createRoleAdminRoute.route, createRoleAdminRoute.handler); + + const create = async () => + await app.request("/create", { + body: JSON.stringify({ + color: "#0a0", + name: [{ languageCode: "en", value: "Editors" }], + }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + + return { create, memory }; +}; + +describe("POST /admin/roles/create - audit", () => { + it("records role.created with the new role's values", async () => { + const h = await harness(); + + const res = await h.create(); + const { id } = (await res.json()) as { id: number }; + + expect(res.status).toBe(201); + expect(h.memory.rows(core_audit_logs)).toEqual([ + expect.objectContaining({ + action: "role.created", + actorId: CALLER.id, + actorType: "staff", + area: "admin", + changes: expect.objectContaining({ + color: { from: null, to: "#0a0" }, + name: { from: null, to: "Editors" }, + }), + targetId: String(id), + targetLabel: "Editors", + targetType: "role", + }), + ]); + }); + + it("creates no role when its audit entry cannot be written", async () => { + const h = await harness({ failAudit: true }); + + const res = await h.create(); + + expect(res.status).toBe(500); + expect(h.memory.rows(core_roles)).toEqual([]); + expect(h.memory.rows(core_audit_logs)).toEqual([]); + }); +}); diff --git a/packages/vitnode/src/api/modules/admin/roles/routes/create.route.ts b/packages/vitnode/src/api/modules/admin/roles/routes/create.route.ts index 3d77cc2089..4ce4c6ebac 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, @@ -111,33 +114,54 @@ 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 }), - ...(allowBrowseAnonymously === undefined - ? {} - : { allowBrowseAnonymously }), - forceBrowseAnonymously: forceBrowseAnonymously ?? false, - ...(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 }), + ...(allowBrowseAnonymously === undefined + ? {} + : { allowBrowseAnonymously }), + forceBrowseAnonymously: forceBrowseAnonymously ?? false, + ...(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 ac8770379f..787eaa29d2 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"; @@ -93,7 +96,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); @@ -159,7 +162,30 @@ export const updateRoleAdminRoute = buildRoute({ values.maxCoverSize = body.maxCoverSize; } - await db.update(core_roles).set(values).where(eq(core_roles.id, roleId)); + const label = await roleAuditLabel(c, roleId); + const renamed = + body.name === undefined ? label : multiLangAuditLabel(body.name); + + await db.transaction(async tx => { + await tx.update(core_roles).set(values).where(eq(core_roles.id, roleId)); + + const changes = auditDiff({ + after: { ...role, ...values, name: renamed }, + before: { ...role, name: label }, + exclude: ["createdAt", "id", "updatedAt"], + }); + if (changes) { + await writeAuditLog( + c, + { + action: "role.updated", + changes, + target: { id: roleId, label: renamed, type: "role" }, + }, + tx, + ); + } + }); if (body.name !== undefined) { await saveLanguageWords(c, { diff --git a/packages/vitnode/src/api/modules/admin/staff/routes/create.route.ts b/packages/vitnode/src/api/modules/admin/staff/routes/create.route.ts index 1bd421e10e..b31ec09e40 100644 --- a/packages/vitnode/src/api/modules/admin/staff/routes/create.route.ts +++ b/packages/vitnode/src/api/modules/admin/staff/routes/create.route.ts @@ -2,6 +2,8 @@ import { z } from "@hono/zod-openapi"; import { eq, or } from "drizzle-orm"; import { buildRoute } from "@/api/builders"; +import { staffEntryAuditLabel } from "@/api/lib/audit/labels"; +import { writeAuditLog } from "@/api/lib/audit/write"; import { assertStaffPermission } from "@/api/lib/check-staff-permission"; import { invalidateStaffEntry } from "@/api/lib/staff-permission-cache"; import { core_admin_permissions } from "@/database/admins"; @@ -112,14 +114,28 @@ export const createStaffAdminRoute = buildRoute({ return c.json({ error: "Staff entry already exists" }, 409); } - const [created] = await c - .get("db") - .insert(table) - .values({ - roleId: roleId ?? null, - userId: userId ?? null, - }) - .returning({ id: table.id }); + const label = await staffEntryAuditLabel(c, { roleId, userId }); + const created = await c.get("db").transaction(async tx => { + const [inserted] = await tx + .insert(table) + .values({ + roleId: roleId ?? null, + userId: userId ?? null, + }) + .returning({ id: table.id }); + + await writeAuditLog( + c, + { + action: `${type}_permissions.created`, + metadata: roleId ? { roleId } : { userId }, + target: { id: inserted.id, label, type: "staff_entry" }, + }, + tx, + ); + + return inserted; + }); await invalidateStaffEntry(c, { roleId, userId }); diff --git a/packages/vitnode/src/api/modules/admin/staff/routes/delete.route.ts b/packages/vitnode/src/api/modules/admin/staff/routes/delete.route.ts index 68185a7aef..76efeb4aee 100644 --- a/packages/vitnode/src/api/modules/admin/staff/routes/delete.route.ts +++ b/packages/vitnode/src/api/modules/admin/staff/routes/delete.route.ts @@ -3,6 +3,11 @@ import { eq } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; import { buildRoute } from "@/api/builders"; +import { + staffEntryAuditLabel, + staffGrantsAuditDelta, +} from "@/api/lib/audit/labels"; +import { writeAuditLog } from "@/api/lib/audit/write"; import { assertStaffPermission } from "@/api/lib/check-staff-permission"; import { invalidateStaffEntry } from "@/api/lib/staff-permission-cache"; import { core_admin_permissions } from "@/database/admins"; @@ -93,7 +98,26 @@ export const deleteStaffAdminRoute = buildRoute({ ); await assertCanManageStaffEntry(c, { type, entry }); - await c.get("db").delete(table).where(eq(table.id, entryId)); + const label = await staffEntryAuditLabel(c, entry); + await c.get("db").transaction(async tx => { + await tx.delete(table).where(eq(table.id, entryId)); + await writeAuditLog( + c, + { + action: `${type}_permissions.deleted`, + metadata: { + revoked: entry.unrestricted + ? ["*"] + : staffGrantsAuditDelta(entry.permissions, []).revoked, + ...(entry.roleId === null + ? { userId: entry.userId } + : { roleId: entry.roleId }), + }, + target: { id: entryId, label, type: "staff_entry" }, + }, + tx, + ); + }); await invalidateStaffEntry(c, entry); diff --git a/packages/vitnode/src/api/modules/admin/staff/routes/staff-entry-routes.test.ts b/packages/vitnode/src/api/modules/admin/staff/routes/staff-entry-routes.test.ts index fb14187bac..6636563f73 100644 --- a/packages/vitnode/src/api/modules/admin/staff/routes/staff-entry-routes.test.ts +++ b/packages/vitnode/src/api/modules/admin/staff/routes/staff-entry-routes.test.ts @@ -12,6 +12,7 @@ import type { buildRoute } from "@/api/lib/route"; import { normalizePermissionStaffModules } from "@/api/lib/permission-staff"; import { core_admin_permissions } from "@/database/admins"; +import { core_audit_logs } from "@/database/audit"; import { core_moderators_permissions } from "@/database/moderators"; import { core_roles } from "@/database/roles"; import { core_users_secondary_roles } from "@/database/users"; @@ -160,7 +161,9 @@ const appFor = async ( const storedEntries = (type: PermissionStaffType = "admin") => memory.rows(tableByType[type]); - return { app, storedEntries }; + const auditEntries = () => memory.rows(core_audit_logs); + + return { app, auditEntries, storedEntries }; }; const patch = async ( @@ -405,6 +408,57 @@ describe("updating a staff entry", () => { const remove = async (app: OpenAPIHono, type: PermissionStaffType = "admin") => await app.request(`/entry/${type}/5`, { method: "DELETE" }); +describe("auditing a staff entry update", () => { + it("records the granted and revoked grants, never the whole list", async () => { + const { app, auditEntries } = await appFor( + updatePermissionsStaffAdminRoute, + { + entry: entryFor({ permissions: [VIEW_ADMINS, DELETE_ADMINS] }), + grants: { admin: MANAGE_ADMINS }, + }, + ); + + const res = await patch(app, { + permissions: [VIEW_ADMINS, CREATE_ADMINS], + unrestricted: false, + }); + + expect(res.status).toBe(200); + expect(auditEntries()).toEqual([ + expect.objectContaining({ + action: "admin_permissions.updated", + actorId: CALLER.id, + changes: null, + metadata: { + granted: [`${CORE}:staff_admins:can_create`], + revoked: [`${CORE}:staff_admins:can_delete`], + roleId: TARGET_ROLE_ID, + }, + targetId: String(entryFor().id), + targetType: "staff_entry", + }), + ]); + }); + + it("records nothing when the grants did not move", async () => { + const { app, auditEntries } = await appFor( + updatePermissionsStaffAdminRoute, + { + entry: entryFor({ permissions: [VIEW_ADMINS] }), + grants: { admin: MANAGE_ADMINS }, + }, + ); + + const res = await patch(app, { + permissions: [VIEW_ADMINS], + unrestricted: false, + }); + + expect(res.status).toBe(200); + expect(auditEntries()).toEqual([]); + }); +}); + describe("removing a staff entry", () => { it("removes an entry within the caller's own permissions", async () => { const { app, storedEntries } = await appFor(deleteStaffAdminRoute, { diff --git a/packages/vitnode/src/api/modules/admin/staff/routes/update-permissions.route.ts b/packages/vitnode/src/api/modules/admin/staff/routes/update-permissions.route.ts index d0c8b3fa4b..ebf9236d08 100644 --- a/packages/vitnode/src/api/modules/admin/staff/routes/update-permissions.route.ts +++ b/packages/vitnode/src/api/modules/admin/staff/routes/update-permissions.route.ts @@ -5,10 +5,16 @@ import { HTTPException } from "hono/http-exception"; import type { PermissionsStaffArgs } from "@/api/lib/permission-staff"; import { buildRoute } from "@/api/builders"; +import { + staffEntryAuditLabel, + staffGrantsAuditDelta, +} from "@/api/lib/audit/labels"; +import { writeAuditLog } from "@/api/lib/audit/write"; import { assertStaffPermission } from "@/api/lib/check-staff-permission"; import { invalidateStaffEntry } from "@/api/lib/staff-permission-cache"; import { core_admin_permissions } from "@/database/admins"; import { core_moderators_permissions } from "@/database/moderators"; +import { auditDiff } from "@/lib/audit/diff"; import { manifest } from "@/manifest"; import { @@ -149,12 +155,42 @@ export const updatePermissionsStaffAdminRoute = buildRoute({ ? [] : await sanitizeStaffGrants(c, { permissions, type }); - const [updated] = await c - .get("db") - .update(table) - .set({ unrestricted, permissions: sanitized }) - .where(eq(table.id, entryId)) - .returning({ id: table.id }); + const label = await staffEntryAuditLabel(c, entry); + const updated = await c.get("db").transaction(async tx => { + const [row] = await tx + .update(table) + .set({ unrestricted, permissions: sanitized }) + .where(eq(table.id, entryId)) + .returning({ id: table.id }); + if (!row) return null; + + // The grants that moved, never the whole list: what an auditor asks is + // "who was given what, and by whom". + const delta = staffGrantsAuditDelta(entry.permissions, sanitized); + const changes = auditDiff({ + after: { unrestricted }, + before: { unrestricted: entry.unrestricted }, + }); + if (changes || delta.granted.length > 0 || delta.revoked.length > 0) { + await writeAuditLog( + c, + { + action: `${type}_permissions.updated`, + changes, + metadata: { + ...delta, + ...(entry.roleId === null + ? { userId: entry.userId } + : { roleId: entry.roleId }), + }, + target: { id: entryId, label, type: "staff_entry" }, + }, + tx, + ); + } + + return row; + }); if (!updated) { return c.json({ error: "Staff entry not found" }, 404); diff --git a/packages/vitnode/src/api/modules/admin/users/lib/audit-changes.ts b/packages/vitnode/src/api/modules/admin/users/lib/audit-changes.ts new file mode 100644 index 0000000000..48e48f17a8 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/lib/audit-changes.ts @@ -0,0 +1,50 @@ +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. A field only counts as + * changed when its value differs from `before`, so `before` must hold every + * field `after` may carry. + */ +export const adminUserAuditChanges = ({ + after, + before, + secondaryRoleIds, +}: { + after: Record; + before: Record; + secondaryRoleIds?: { after: number[]; before: number[] }; +}): AuditChanges | null => { + const isVisible = (field: string) => + (VISIBLE_FIELDS as readonly string[]).includes(field); + const changes: AuditChanges = {}; + for (const [field, change] of Object.entries( + auditDiff({ after, before, include: Object.keys(after) }) ?? {}, + )) { + changes[field] = isVisible(field) ? change : { 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..fcc4962b9d 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,22 @@ export const updateUserAdminRoute = buildRoute({ } const [user] = await db - .select({ id: core_users.id, roleId: core_users.roleId }) + .select({ + birthday: core_users.birthday, + email: core_users.email, + firstName: core_users.firstName, + headline: core_users.headline, + id: core_users.id, + language: core_users.language, + lastName: core_users.lastName, + name: core_users.name, + nameCode: core_users.nameCode, + newsletter: core_users.newsletter, + phone: core_users.phone, + roleId: core_users.roleId, + showRealName: core_users.showRealName, + timeZone: core_users.timeZone, + }) .from(core_users) .where(eq(core_users.id, userId)) .limit(1); @@ -309,20 +326,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 +404,7 @@ export const updateUserAdminRoute = buildRoute({ const rolesChanged = body.roleId !== undefined || secondaryRoleIds !== undefined; - if (Object.keys(values).length > 0) { - const [updated] = await db - .update(core_users) - .set(values) - .where(eq(core_users.id, user.id)) - .returning({ - id: core_users.id, - name: core_users.name, - email: core_users.email, - nameCode: core_users.nameCode, - }); - + if (updated) { if (values.email !== undefined) { await revokeAccountRecoveryTokens(c, user.id); } diff --git a/packages/vitnode/src/api/modules/admin/users/routes/verify-email.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/verify-email.route.ts index 569a022817..cfe8fed1b2 100644 --- a/packages/vitnode/src/api/modules/admin/users/routes/verify-email.route.ts +++ b/packages/vitnode/src/api/modules/admin/users/routes/verify-email.route.ts @@ -2,6 +2,7 @@ import { z } from "@hono/zod-openapi"; import { eq } from "drizzle-orm"; import { buildRoute } from "@/api/builders"; +import { writeAuditLog } from "@/api/lib/audit/write"; import { invalidateSessionCacheForUser } from "@/api/models/session-revoke"; import { core_users, core_users_confirm_emails } from "@/database/users"; @@ -66,19 +67,34 @@ export const verifyEmailUserAdminRoute = buildRoute({ await assertCanEditAdminTarget(c, userId); - const [updated] = await db - .update(core_users) - .set({ emailVerified: true }) - .where(eq(core_users.id, user.id)) - .returning({ - email: core_users.email, - name: core_users.name, - emailVerified: core_users.emailVerified, - }); + const updated = await db.transaction(async tx => { + const [row] = await tx + .update(core_users) + .set({ emailVerified: true }) + .where(eq(core_users.id, user.id)) + .returning({ + email: core_users.email, + name: core_users.name, + emailVerified: core_users.emailVerified, + }); - await db - .delete(core_users_confirm_emails) - .where(eq(core_users_confirm_emails.userId, user.id)); + await tx + .delete(core_users_confirm_emails) + .where(eq(core_users_confirm_emails.userId, user.id)); + + if (!user.emailVerified) { + await writeAuditLog( + c, + { + action: "user.email_verified", + target: { id: user.id, label: row.name, type: "user" }, + }, + tx, + ); + } + + return row; + }); if (!user.emailVerified) { await invalidateSessionCacheForUser(c, user.id); await c diff --git a/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.test.ts b/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.test.ts index 2ba4d23c59..9ef5fd502f 100644 --- a/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.test.ts +++ b/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.test.ts @@ -5,7 +5,12 @@ import { getTableName } from "drizzle-orm"; import { PgDialect } from "drizzle-orm/pg-core"; import { describe, expect, it, vi } from "vitest"; +import type { ContentActor } from "@/content/revisions"; +import type { ContentDatabase } from "@/content/server/service"; + +import { recordContentAudit } from "@/content/server/audit"; import { core_content_schedules } from "@/database/content"; +import { createAuditRecorder } from "@/tests/audit-recorder"; import { testEditorialPostContentType } from "@/tests/content-fixtures"; import { executeContentSchedule } from "./execute-content-schedule"; @@ -106,7 +111,10 @@ const harness = ({ const settlements: Settlement[] = []; let committed = false; + const audit = createAuditRecorder(); + const handleFor = (handle: Handle) => ({ + insert: (table: unknown) => audit.insert(table), select: () => ({ from: (table: typeof core_content_schedules) => ({ where: (condition: SQL | undefined) => ({ @@ -171,6 +179,7 @@ const harness = ({ } as unknown as Context; return { + audits: audit.entries, c, claims, committed: () => committed, @@ -289,6 +298,46 @@ describe("executeContentSchedule", () => { }); }); + it("audits the publish as the system, naming who booked it", async () => { + const { audits, c, publish } = harness(); + // What the editorial capture does: record the transition in its own + // transaction, as the actor it was given. + publish.mockImplementation( + async ( + itemId: number, + options: { actor: ContentActor; tx: ContentDatabase }, + ) => { + await recordContentAudit(c, options.tx, { + action: "published", + actor: options.actor, + definition: testEditorialPostContentType, + itemId, + pluginId: PLUGIN_ID, + }); + + return outcome; + }, + ); + + await executeContentSchedule(c, { generation: 1, scheduleId: 55 }); + + expect(audits).toEqual([ + expect.objectContaining({ + action: "content.published", + actorId: null, + actorType: "system", + area: "system", + correlationId: "schedule:55", + ipAddress: null, + metadata: { + scheduleId: 55, + scheduledByUserId: 3, + trigger: "scheduled_task", + }, + }), + ]); + }); + describe("the effects payload", () => { it("names the person who booked it", async () => { // The actor is genuinely the system, so "on whose instruction" has to diff --git a/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.ts b/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.ts index 517f094577..c151b92767 100644 --- a/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.ts +++ b/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.ts @@ -5,6 +5,7 @@ import type { ContentEditorialOutcome } from "@/content/server/editorial-service import type { ContentScheduleEffectsPayload } from "@/content/server/schedule-effects"; import type { AnyContentTypeDefinition } from "@/content/types"; +import { AUDIT_SYSTEM_ACTOR, withAuditContext } from "@/api/lib/audit/context"; import { CONTENT_QUEUE_TASK_SCHEDULE_EFFECTS } from "@/content/const"; import { contentIdFromKey } from "@/content/ids"; import { CONTENT_SYSTEM_ACTOR } from "@/content/server/actor"; @@ -176,14 +177,30 @@ export const executeContentSchedule = async ( return { kind: "skipped", reason: "not an identifier of this type" }; } - const outcome = await editorialService(c, { pluginId })[claimed.action]( - itemId, + // Executed by the system, and audited as such: whoever booked the + // schedule did not press the button now. Their id, the schedule and the + // trigger ride along in the entry's metadata. The entry is written in this + // transaction, so a rolled-back or retried run leaves none, and the claim + // above means a settled schedule never runs - or is audited - twice. + const outcome = await withAuditContext( + c, { - // No fake user id anywhere. Who *asked* for this is on the schedule - // row and travels in the event as `scheduledBy`. - actor: CONTENT_SYSTEM_ACTOR, - tx, + actor: AUDIT_SYSTEM_ACTOR, + area: "system", + correlationId: `schedule:${claimed.id}`, + metadata: { + scheduledByUserId: claimed.createdBy, + scheduleId: claimed.id, + trigger: "scheduled_task", + }, }, + async () => + await editorialService(c, { pluginId })[claimed.action](itemId, { + // No fake user id anywhere. Who *asked* for this is on the schedule + // row and travels in the event as `scheduledBy`. + actor: CONTENT_SYSTEM_ACTOR, + tx, + }), ); // Settled whatever happened. A record that was deleted first, or is diff --git a/packages/vitnode/src/api/modules/cron/cron.module.ts b/packages/vitnode/src/api/modules/cron/cron.module.ts index cc3dda7b72..1e4d0dc384 100644 --- a/packages/vitnode/src/api/modules/cron/cron.module.ts +++ b/packages/vitnode/src/api/modules/cron/cron.module.ts @@ -1,5 +1,6 @@ import { buildModule } from "@/api/builders"; +import { auditRetentionCron } from "./cron/audit-retention.cron"; import { checkUpdatesCron } from "./cron/check-updates.cron"; import { cleanCron } from "./cron/clean.cron"; import { runCronRoute } from "./routes/cron.route"; @@ -7,5 +8,5 @@ import { runCronRoute } from "./routes/cron.route"; export const cronModule = buildModule({ name: "cron", routes: [runCronRoute], - cronJobs: [cleanCron, checkUpdatesCron], + cronJobs: [cleanCron, checkUpdatesCron, auditRetentionCron], }); diff --git a/packages/vitnode/src/api/modules/cron/cron/audit-retention.cron.ts b/packages/vitnode/src/api/modules/cron/cron/audit-retention.cron.ts new file mode 100644 index 0000000000..574c9e0b12 --- /dev/null +++ b/packages/vitnode/src/api/modules/cron/cron/audit-retention.cron.ts @@ -0,0 +1,23 @@ +import { pruneAuditLogs } from "@/api/lib/audit/retention"; +import { loadAuditSettings } from "@/api/lib/audit/settings"; +import { writeAuditLog } from "@/api/lib/audit/write"; +import { buildCron } from "@/api/lib/cron"; + +export const auditRetentionCron = buildCron({ + name: "audit-retention", + description: + "Delete Activity Logs entries older than the retention period set in the AdminCP", + schedule: "40 3 * * *", + handler: async c => { + const { retentionDays } = await loadAuditSettings(c.get("db")); + const deleted = await pruneAuditLogs(c.get("db"), { retentionDays }); + if (deleted === 0) return; + + // The one deletion the log ever sees, and it says so. + await writeAuditLog(c, { + action: "audit_logs.pruned", + metadata: { deleted, retentionDays }, + target: { label: "Activity Logs", type: "settings", id: "activity_logs" }, + }); + }, +}); diff --git a/packages/vitnode/src/api/modules/cron/routes/cron.route.ts b/packages/vitnode/src/api/modules/cron/routes/cron.route.ts index 849cfa27dc..7d6e97365f 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, gte, isNull, lt } 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"; @@ -123,7 +124,17 @@ export const runCronRoute = buildRoute({ } try { - await job.handler(c); + await withAuditContext( + c, + { + actor: AUDIT_SYSTEM_ACTOR, + area: "system", + metadata: { cronJob: job.name, trigger: "cron" }, + }, + async () => { + await job.handler(c); + }, + ); return { success: true, jobName: job.name }; } catch (error) { diff --git a/packages/vitnode/src/api/modules/moderation/lib/audit.ts b/packages/vitnode/src/api/modules/moderation/lib/audit.ts new file mode 100644 index 0000000000..837cefe397 --- /dev/null +++ b/packages/vitnode/src/api/modules/moderation/lib/audit.ts @@ -0,0 +1,91 @@ +import type { Context } from "hono"; + +import type { ContentDatabase } from "@/content/server/service"; +import type { AuditChanges } from "@/lib/audit/const"; + +import { withAuditContext } from "@/api/lib/audit/context"; +import { writeAuditLog } from "@/api/lib/audit/write"; + +import type { ReportRow } from "./reports"; + +type AuditedReport = Pick< + ReportRow, + | "id" + | "pluginId" + | "scopeFamily" + | "scopeId" + | "targetId" + | "targetTitle" + | "targetType" +>; + +const scopeOf = (report: AuditedReport) => + report.scopeFamily && report.scopeId + ? { family: report.scopeFamily, id: report.scopeId } + : null; + +/** + * Runs one moderator operation on a report: every audit entry written inside - + * the report's own and any its action causes, such as hiding the reported + * post - is attributed to the ModeratorCP and to the report's moderation + * scope, so a moderator of that scope (and only of that scope) sees it. + */ +export const withReportAudit = async ( + c: Context, + report: AuditedReport, + body: () => Promise, +): Promise => + await withAuditContext( + c, + { + area: "moderator", + metadata: { reportId: report.id }, + scope: scopeOf(report), + }, + body, + ); + +/** + * The global audit entry of a report workflow step, written in the same + * transaction as the report update and its own timeline event. The report's + * timeline (`core_moderation_report_events`) stays the detailed history; this + * entry is what a cross-plugin activity view lists. + */ +export const recordReportAudit = async ( + c: Context, + tx: ContentDatabase, + report: AuditedReport, + { + action, + changes = null, + metadata, + }: { + action: string; + changes?: AuditChanges | null; + metadata?: Record; + }, +): Promise => { + await writeAuditLog( + c, + { + action: `report.${action}`, + area: "moderator", + changes, + metadata: { + reportedPluginId: report.pluginId, + reportedTargetId: report.targetId, + reportedTargetType: report.targetType, + ...metadata, + }, + scope: scopeOf(report), + target: { + id: report.id, + label: report.targetTitle + ? `#${report.id} ${report.targetTitle}` + : `#${report.id}`, + type: "report", + }, + }, + tx, + ); +}; diff --git a/packages/vitnode/src/api/modules/moderation/reports.test.ts b/packages/vitnode/src/api/modules/moderation/reports.test.ts index 044a8fbe1b..5ad6cf4d50 100644 --- a/packages/vitnode/src/api/modules/moderation/reports.test.ts +++ b/packages/vitnode/src/api/modules/moderation/reports.test.ts @@ -7,6 +7,7 @@ import type { PermissionsStaffArgs } from "@/api/lib/permission-staff"; import type { ModerationCaptureResult } from "@/api/moderation"; import { defineModerationScope, defineReportTarget } from "@/api/moderation"; +import { core_audit_logs } from "@/database/audit"; import { core_files } from "@/database/files"; import { core_moderation_report_events, @@ -553,6 +554,39 @@ describe("working a report", () => { ); }); + it("records a claim in the activity log next to the report's own timeline", async () => { + const h = await harness(); + const id = await h.reportId(REPORTER, "1"); + + const res = await h.call(MOD_A, "POST", `/reports/${id}/claim`, { + expectedVersion: 1, + }); + + expect(res.status).toBe(200); + expect( + h.memory + .rows(core_audit_logs) + .filter(entry => entry.action === "report.claimed"), + ).toEqual([ + expect.objectContaining({ + actorId: MOD_A, + actorType: "staff", + area: "moderator", + changes: { + assigneeId: { from: null, to: MOD_A }, + status: { from: "new", to: "in_progress" }, + }, + scopeFamily: FAMILY, + scopeId: SECTION_A, + targetId: String(id), + targetType: "report", + }), + ]); + expect(h.eventsOf(id).map(event => event.type)).toEqual( + expect.arrayContaining(["assigned", "status_changed"]), + ); + }); + it("resolves with an outcome and reopens with the history kept", async () => { const h = await harness(); const id = await h.reportId(REPORTER, "1"); diff --git a/packages/vitnode/src/api/modules/moderation/routes/notes.routes.ts b/packages/vitnode/src/api/modules/moderation/routes/notes.routes.ts index f73a11e8c0..964b5a8dd9 100644 --- a/packages/vitnode/src/api/modules/moderation/routes/notes.routes.ts +++ b/packages/vitnode/src/api/modules/moderation/routes/notes.routes.ts @@ -5,6 +5,7 @@ import { buildRoute } from "@/api/builders"; import { requireModerator } from "@/api/lib/moderation/access"; import { core_moderation_report_events } from "@/database/moderation"; +import { recordReportAudit } from "../lib/audit"; import { canChangeNote, loadReportNote } from "../lib/notes"; import { appendReportEvent, @@ -80,6 +81,10 @@ export const editReportNoteRoute = buildRoute({ reportId: report.id, type: "note_edited", }); + await recordReportAudit(c, tx, report, { + action: "note_edited", + metadata: { noteAuthorId: note.actorId, noteId: note.id }, + }); }); await c.get("events").emit("moderation.report.note_updated", { @@ -119,6 +124,10 @@ export const deleteReportNoteRoute = buildRoute({ reportId: report.id, type: "note_deleted", }); + await recordReportAudit(c, tx, report, { + action: "note_deleted", + metadata: { noteAuthorId: note.actorId, noteId: note.id }, + }); }); await c.get("events").emit("moderation.report.note_deleted", { diff --git a/packages/vitnode/src/api/modules/moderation/routes/workflow.routes.ts b/packages/vitnode/src/api/modules/moderation/routes/workflow.routes.ts index 34f39426c8..a4fa8dcce5 100644 --- a/packages/vitnode/src/api/modules/moderation/routes/workflow.routes.ts +++ b/packages/vitnode/src/api/modules/moderation/routes/workflow.routes.ts @@ -6,8 +6,10 @@ import { resolveStaffPermissions } from "@/api/lib/check-staff-permission"; import { canOnReport, requireModerator } from "@/api/lib/moderation/access"; import { core_moderation_reports } from "@/database/moderation"; import { core_users } from "@/database/users"; +import { auditDiff } from "@/lib/audit/diff"; import { MODERATION_PERMISSIONS } from "@/lib/moderation/const"; +import { recordReportAudit, withReportAudit } from "../lib/audit"; import { listModeratorCandidates } from "../lib/candidates"; import { loadReportPeople } from "../lib/people"; import { @@ -88,6 +90,13 @@ export const claimReportRoute = buildRoute({ assigneeId: moderator.user.id, status: "in_progress", }); + await recordReportAudit(c, tx, report, { + action: "claimed", + changes: auditDiff({ + after: { assigneeId: moderator.user.id, status: "in_progress" }, + before: { assigneeId: report.assigneeId, status: report.status }, + }), + }); if (report.assigneeId !== moderator.user.id) { await appendReportEvent(tx, { actorId: moderator.user.id, @@ -180,6 +189,13 @@ export const assignReportRoute = buildRoute({ const status = assigneeId === null ? "new" : "in_progress"; const updated = await c.get("db").transaction(async tx => { const next = await updateReport(tx, report, { assigneeId, status }); + await recordReportAudit(c, tx, report, { + action: assigneeId === null ? "unassigned" : "assigned", + changes: auditDiff({ + after: { assigneeId, status }, + before: { assigneeId: report.assigneeId, status: report.status }, + }), + }); await appendReportEvent(tx, { actorId: moderator.user.id, data: { assigneeId, previousAssigneeId: report.assigneeId }, @@ -234,11 +250,16 @@ export const noteReportRoute = buildRoute({ manage: true, }); - await appendReportEvent(c.get("db"), { - actorId: moderator.user.id, - data: { body: note }, - reportId: report.id, - type: "note", + await c.get("db").transaction(async tx => { + await appendReportEvent(tx, { + actorId: moderator.user.id, + data: { body: note }, + reportId: report.id, + type: "note", + }); + // That a note was added - its text stays in the report's own timeline, + // behind the report's own permissions. + await recordReportAudit(c, tx, report, { action: "note_added" }); }); return c.json({ ok: true }, 201); @@ -284,6 +305,14 @@ export const resolveReportRoute = buildRoute({ resolvedById: moderator.user.id, status: "done", }); + await recordReportAudit(c, tx, report, { + action: "resolved", + changes: auditDiff({ + after: { resolution, status: "done" }, + before: { resolution: report.resolution, status: report.status }, + }), + metadata: { withExplanation: Boolean(explanation) }, + }); await appendReportEvent(tx, { actorId: moderator.user.id, data: explanation ? { explanation, resolution } : { resolution }, @@ -354,6 +383,13 @@ export const reopenReportRoute = buildRoute({ resolvedById: null, status, }); + await recordReportAudit(c, tx, report, { + action: "reopened", + changes: auditDiff({ + after: { resolution: null, status }, + before: { resolution: report.resolution, status: report.status }, + }), + }); await appendReportEvent(tx, { actorId: moderator.user.id, data: { @@ -416,11 +452,19 @@ export const reportActionRoute = buildRoute({ } const result = await c.get("db").transaction(async tx => { - const outcome = await definition.run(c, { - actorUserId: moderator.user.id, - targetId: report.targetId, - tx, - }); + // The content write the action performs is audited by its own owner (the + // Content Engine's `content.hidden`) - as a moderator's, in the report's + // scope. + const outcome = await withReportAudit( + c, + report, + async () => + await definition.run(c, { + actorUserId: moderator.user.id, + targetId: report.targetId, + tx, + }), + ); if (!outcome) return null; if (!outcome.changed) { return { outcome, state: stateOf(report) }; @@ -433,6 +477,10 @@ export const reportActionRoute = buildRoute({ reportId: report.id, type: "action", }); + await recordReportAudit(c, tx, report, { + action: "action_taken", + metadata: { action }, + }); return { outcome, state: stateOf(next) }; }); diff --git a/packages/vitnode/src/api/modules/queue/helpers/process-queue-tasks.ts b/packages/vitnode/src/api/modules/queue/helpers/process-queue-tasks.ts index fa54471602..1869429ef5 100644 --- a/packages/vitnode/src/api/modules/queue/helpers/process-queue-tasks.ts +++ b/packages/vitnode/src/api/modules/queue/helpers/process-queue-tasks.ts @@ -16,6 +16,7 @@ import { import type { QueueTaskConfig } from "@/api/lib/queue"; import type { EnvVitNode } from "@/api/middlewares/global.middleware"; +import { AUDIT_SYSTEM_ACTOR, withAuditContext } from "@/api/lib/audit/context"; import { QueueDeferError } from "@/api/lib/queue"; import { core_queue } from "@/database/queue"; import { resolveQueueTaskOutcome } from "@/lib/api/resolve-queue-task-outcome"; @@ -165,7 +166,18 @@ const runQueueTask = async ( await c.get("log").warn(error); } else { try { - await registered.handler(c, task.payload); + // A task runs as the system, whichever request happened to drain it. + await withAuditContext( + c, + { + actor: AUDIT_SYSTEM_ACTOR, + area: "system", + metadata: { queueTask: key, trigger: "queue_task" }, + }, + async () => { + await registered.handler(c, task.payload); + }, + ); } catch (err) { if (err instanceof QueueDeferError) { return { diff --git a/packages/vitnode/src/api/plugin.ts b/packages/vitnode/src/api/plugin.ts index 5f8057368e..c0809290d8 100644 --- a/packages/vitnode/src/api/plugin.ts +++ b/packages/vitnode/src/api/plugin.ts @@ -105,6 +105,12 @@ export const newBuildPluginApiCore = buildApiPlugin({ ], ai: ["can_view", { permission: "can_manage", dependsOn: ["can_view"] }], cron: ["can_view", { permission: "can_run", dependsOn: ["can_view"] }], + activity_logs: [ + "can_view", + { permission: "can_view_sensitive", dependsOn: ["can_view"] }, + { permission: "can_export", dependsOn: ["can_view"] }, + { permission: "can_manage_retention", dependsOn: ["can_view"] }, + ], report_reasons: [ "can_view", { permission: "can_create", dependsOn: ["can_view"] }, diff --git a/packages/vitnode/src/content/approvals/server/decide.ts b/packages/vitnode/src/content/approvals/server/decide.ts index 951ecb2a83..0834670631 100644 --- a/packages/vitnode/src/content/approvals/server/decide.ts +++ b/packages/vitnode/src/content/approvals/server/decide.ts @@ -12,6 +12,8 @@ import type { AnyContentTypeDefinition } from "../../types"; import type { ContentSubmissionDecisionCode } from "../const"; import type { ContentSubmissionRow } from "./submissions"; +import { withAuditContext } from "../../../api/lib/audit/context"; +import { writeAuditLog } from "../../../api/lib/audit/write"; import { core_content_submissions } from "../../../database/content-submissions"; import { CONTENT_PERMISSIONS } from "../../const"; import { contentContainerSourceOf } from "../../containers/association"; @@ -21,6 +23,7 @@ import { } from "../../containers/server/policy"; import { ContentInputError, ContentVersionConflict } from "../../errors"; import { parseContentId } from "../../ids"; +import { contentAuditLabel } from "../../server/audit"; import { contentDefinitionOf } from "../../server/model"; import { recordContentModerationDecision } from "../../server/notifications/intents"; import { publicationMethods } from "../../server/publication"; @@ -260,17 +263,26 @@ export const decideContentSubmission = async ( }); if (!allowed) throw new ContentSubmissionDecisionError("forbidden"); - const applied: Applied = - decision === "approve" - ? await applyApproval(c, tx, { - actor, - id: itemId, - model, - pluginId: submission.pluginId, - row, - submission, - }) - : { kind: "none" }; + // A moderator's decision, in the container's moderation scope - for the + // decision's own entry and for the write that approving it performs. + const scope = container + ? { family: container.definition.id, id: String(container.container.id) } + : null; + const applied: Applied = await withAuditContext( + c, + { area: "moderator", scope }, + async () => + decision === "approve" + ? await applyApproval(c, tx, { + actor, + id: itemId, + model, + pluginId: submission.pluginId, + row, + submission, + }) + : { kind: "none" }, + ); const [decided] = await tx .update(core_content_submissions) @@ -293,6 +305,29 @@ export const decideContentSubmission = async ( // decision commits. await recordContentModerationDecision(c, tx, decided); + await writeAuditLog( + c, + { + action: `submission.${decision === "approve" ? "approved" : "rejected"}`, + actor, + area: "moderator", + contentTypeId: definition.id, + metadata: { + contentId: submission.itemId, + kind: submission.kind, + ...(decision === "reject" ? { withReason: Boolean(reason) } : {}), + }, + pluginId: submission.pluginId, + scope, + target: { + id: submission.id, + label: contentAuditLabel(definition, row), + type: "submission", + }, + }, + tx, + ); + return { applied, decided, kind: "decided" as const, model }; }); diff --git a/packages/vitnode/src/content/define-audit.test.ts b/packages/vitnode/src/content/define-audit.test.ts new file mode 100644 index 0000000000..d5436cee07 --- /dev/null +++ b/packages/vitnode/src/content/define-audit.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; + +import type { ContentAuditInput } from "./types"; + +import { defineContentType } from "./define"; +import { ContentEngineError } from "./errors"; +import { field } from "./fields"; + +const fields = { + title: field.text({ required: true }), + views: field.number({ integer: true, defaultValue: 0 }), +}; + +const define = (audit?: ContentAuditInput | false) => + defineContentType({ + id: "test.audited", + tableName: "test_audited", + fields, + ...(audit === undefined ? {} : { audit }), + }); + +describe("audit", () => { + it.each([ + [ + "is on and tracks changes by default", + undefined, + { enabled: true, excludeFields: [], trackChanges: true }, + ], + [ + "is off with false", + false, + { enabled: false, excludeFields: [], trackChanges: false }, + ], + [ + "keeps the excluded fields and the trackChanges choice", + { excludeFields: ["views"], trackChanges: false }, + { enabled: true, excludeFields: ["views"], trackChanges: false }, + ], + ] as const)("%s", (_label, audit, resolved) => { + expect(define(audit).audit).toEqual(resolved); + }); + + it("rejects an excluded field the content type does not have", () => { + expect(() => + define({ + excludeFields: ["missing" as keyof typeof fields], + }), + ).toThrow(ContentEngineError); + }); +}); diff --git a/packages/vitnode/src/content/define.ts b/packages/vitnode/src/content/define.ts index 62fc25743c..3364b4fdb0 100644 --- a/packages/vitnode/src/content/define.ts +++ b/packages/vitnode/src/content/define.ts @@ -2,6 +2,7 @@ import type { ContentIdStrategy } from "./ids"; import type { AnyContentTypeDefinition, ContentAdminConfig, + ContentAuditInput, ContentDeliveryConfig, ContentDeliveryDescriptionField, ContentDeliveryEnabled, @@ -37,6 +38,7 @@ import type { ContentSearchTitleField, ContentThreadInput, ContentTypeDefinition, + ResolvedContentAuditConfig, ResolvedContentDeliveryConfig, ResolvedContentDuplicationConfig, ResolvedContentEditorialConfig, @@ -159,6 +161,38 @@ const assertIndexable = ( } }; +const resolveContentAudit = ( + id: string, + audit: ContentAuditInput | false | undefined, + fieldNames: readonly string[], +): ResolvedContentAuditConfig => { + if (audit === false) { + return { enabled: false, excludeFields: [], trackChanges: false }; + } + if (audit !== undefined && (typeof audit !== "object" || audit === null)) { + throw new ContentEngineError( + "audit is `false` or an options object. Omit it to keep the default.", + { contentTypeId: id }, + ); + } + + const excludeFields = [...(audit?.excludeFields ?? [])]; + for (const field of excludeFields) { + if (!fieldNames.includes(field)) { + throw new ContentEngineError( + `audit.excludeFields references unknown field "${field}".`, + { contentTypeId: id }, + ); + } + } + + return { + enabled: true, + excludeFields, + trackChanges: audit?.trackChanges ?? true, + }; +}; + /** * Declares a Content Engine content type: its fields, its storage and the * capabilities (publication, localization, public API, editorial workflow, @@ -268,6 +302,7 @@ export const defineContentType = < TPublicListField extends TPublicField = never, >({ admin = {}, + audit, delivery, duplication, editorial, @@ -297,6 +332,14 @@ export const defineContentType = < ContentEditorialEnabled >; + /** + * Automatic audit logging: every create, edit, publication, visibility, + * schedule and translation write is recorded in the central audit log with + * its actor and changed fields. On by default; `false` turns it off, and + * `excludeFields` keeps noisy fields out of the recorded changes. + */ + audit?: ContentAuditInput> | false; + /** * Public delivery: canonical URLs (`/{publicApi.path}/:slug` by default), SEO * metadata, localized alternates, sitemap entries and, with `redirects`, slug @@ -721,6 +764,7 @@ export const defineContentType = < > = { admin: resolvedAdmin, advanced: resolvedAdvanced, + audit: resolveContentAudit(id, audit, fieldNames), delivery: resolvedDelivery as ResolvedContentDeliveryConfig< ContentDeliveryEnabled >, diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts index 7e3e251646..e8f62ade8b 100644 --- a/packages/vitnode/src/content/index.ts +++ b/packages/vitnode/src/content/index.ts @@ -543,6 +543,7 @@ export type { ContentAdminFormMode, ContentAdminFormSection, ContentAdminListConfig, + ContentAuditInput, ContentBlocksField, ContentBooleanField, ContentCreateInput, @@ -658,6 +659,7 @@ export type { PublicFilterableContentFieldName, PublicWritableContentTypeDefinition, ResolvedContentAdminConfig, + ResolvedContentAuditConfig, ResolvedContentDeliveryConfig, ResolvedContentDeliverySeoConfig, ResolvedContentDuplicationConfig, diff --git a/packages/vitnode/src/content/server/audit.test.ts b/packages/vitnode/src/content/server/audit.test.ts new file mode 100644 index 0000000000..3381ade5be --- /dev/null +++ b/packages/vitnode/src/content/server/audit.test.ts @@ -0,0 +1,148 @@ +import type { Context } from "hono"; + +import { describe, expect, it } from "vitest"; + +import type { AnyContentTypeDefinition } from "../types"; +import type { ContentAuditWrite } from "./audit"; +import type { ContentDatabase } from "./service"; + +import { writeAuditLog } from "../../api/lib/audit/write"; +import { core_audit_logs } from "../../database/audit"; +import { core_users } from "../../database/users"; +import { createMemoryDb } from "../../tests/memory-db"; +import { defineContentType } from "../define"; +import { field } from "../fields"; +import { + collectContentTranslationAudit, + recordContentAudit, + withContentDuplicateAudit, +} from "./audit"; + +const definition = defineContentType({ + id: "test.note", + tableName: "test_notes", + fields: { + title: field.text({ required: true }), + views: field.number({ integer: true, defaultValue: 0 }), + }, + audit: { excludeFields: ["views"] }, +}) as AnyContentTypeDefinition; + +const EDITOR = { id: 4, name: "Editor" }; + +const harness = () => { + const memory = createMemoryDb([[core_users, [EDITOR]]]); + const c = { + get: (key: string) => + key === "db" ? memory.db : key === "admin" ? { user: EDITOR } : undefined, + } as unknown as Context; + const tx = memory.db as unknown as ContentDatabase; + + const record = async (overrides: Partial) => + await recordContentAudit(c, tx, { + action: "created", + actor: { type: "staff", userId: EDITOR.id }, + definition, + itemId: 9, + pluginId: "@vitnode/notes", + ...overrides, + }); + + return { + c, + entries: () => memory.rows(core_audit_logs), + record, + }; +}; + +describe("recordContentAudit", () => { + it("names the changed fields without the excluded ones", async () => { + const h = harness(); + + await h.record({ action: "updated", changedFields: ["title", "views"] }); + + expect(h.entries()[0]).toMatchObject({ + action: "content.updated", + contentTypeId: "test.note", + metadata: { changedFields: ["title"] }, + pluginId: "@vitnode/notes", + targetType: "test.note", + }); + }); + + it("drops values and field names when trackChanges is off", async () => { + const h = harness(); + + await h.record({ + action: "updated", + changedFields: ["title"], + changes: { title: { from: "a", to: "b" } }, + definition: { + ...definition, + audit: { ...definition.audit, trackChanges: false }, + }, + }); + + expect(h.entries()[0]).toMatchObject({ changes: null, metadata: {} }); + }); + + it("writes a system actor's entry to the system area", async () => { + const h = harness(); + + await h.record({ + action: "published", + actor: { type: "system", userId: null }, + }); + + expect(h.entries()[0]).toMatchObject({ + actorId: null, + actorType: "system", + area: "system", + ipAddress: null, + }); + }); +}); + +describe("withContentDuplicateAudit", () => { + it("leaves one content.duplicated entry naming the source, and no translation entries", async () => { + const h = harness(); + + await withContentDuplicateAudit(h.c, definition, 3, async () => { + await h.record({ action: "created" }); + await h.record({ action: "translation_created", locale: "pl" }); + await writeAuditLog(h.c, { + action: "role.updated", + target: { id: 1, type: "role" }, + }); + }); + + expect(h.entries()).toEqual([ + expect.objectContaining({ + action: "content.duplicated", + metadata: { sourceId: 3 }, + targetId: "9", + }), + expect.objectContaining({ action: "role.updated" }), + ]); + }); +}); + +describe("collectContentTranslationAudit", () => { + it("holds back the record's translation entries and writes the rest", async () => { + const h = harness(); + + const { entries } = await collectContentTranslationAudit( + h.c, + definition, + async () => { + await h.record({ action: "translation_published", locale: "pl" }); + await h.record({ action: "published" }); + }, + ); + + expect(entries.map(entry => entry.metadata.locale)).toEqual(["pl"]); + expect(h.entries().map(entry => entry.action)).toEqual([ + "content.published", + ]); + }); +}); diff --git a/packages/vitnode/src/content/server/audit.ts b/packages/vitnode/src/content/server/audit.ts new file mode 100644 index 0000000000..68d5a18cb0 --- /dev/null +++ b/packages/vitnode/src/content/server/audit.ts @@ -0,0 +1,431 @@ +import type { Context } from "hono"; + +import type { AuditPendingEntry } from "../../api/lib/audit/context"; +import type { AuditChanges } from "../../lib/audit/const"; +import type { ContentEventAction } from "../events"; +import type { ContentId } from "../ids"; +import type { ContentActor } from "../revisions"; +import type { AnyContentTypeDefinition } from "../types"; +import type { ContentDatabase } from "./service"; + +import { withAuditContext } from "../../api/lib/audit/context"; +import { writeAuditLog } from "../../api/lib/audit/write"; +import { AUDIT_CORE_PLUGIN_ID } from "../../lib/audit/const"; +import { auditDiff } from "../../lib/audit/diff"; +import { partitionContentFields } from "../localization"; + +/** + * The Content Engine operations that leave an audit entry, named after the + * content event they correspond to. Delivery events are not audited: a slug + * change is already part of the `updated` entry that caused it. + */ +export type ContentAuditAction = Exclude< + ContentEventAction, + "delivery_redirect_created" | "delivery_slug_changed" +>; + +/** The audit action stored for a content operation: `content.updated`. */ +export const contentAuditAction = (action: ContentAuditAction): string => + `content.${action}`; + +/** Revision operations of the shared history, mapped to their audit action. */ +export const CONTENT_REVISION_AUDIT_ACTIONS = { + create: "created", + delete: "deleted", + hide: "hidden", + publish: "published", + restore: "restored", + unhide: "unhidden", + unpublish: "unpublished", + update: "updated", +} as const satisfies Record; + +/** Revision operations of one translation's history, mapped to their audit action. */ +export const CONTENT_TRANSLATION_REVISION_AUDIT_ACTIONS = { + create: "translation_created", + delete: "translation_deleted", + publish: "translation_published", + restore: "translation_restored", + unpublish: "translation_unpublished", + update: "translation_updated", +} as const satisfies Record; + +/** One Content Engine operation to record, inside the write's transaction. */ +export interface ContentAuditWrite { + action: ContentAuditAction; + actor: ContentActor; + /** Canonical field paths the write moved (`seo.title`, `categories`). */ + changedFields?: readonly string[]; + /** + * Bounded before/after values, for a content type without revisions. An + * editorial content type leaves this out: its revision holds the values. + */ + changes?: AuditChanges | null; + definition: AnyContentTypeDefinition; + itemId: ContentId; + /** The record's title at the time of the write, when the row carries one. */ + label?: null | string; + /** Only for a translation operation. */ + locale?: string; + metadata?: Record; + pluginId: string; + revisionId?: null | number; +} + +/** + * The plugin a content type belongs to, from the content registry - never + * from the request. Falls back to the route's plugin for a content type bound + * outside the registry (tests, custom wiring). + */ +export const contentPluginIdOf = ( + c: Context, + definition: AnyContentTypeDefinition, +): string => + c + .get("core") + ?.contentModels?.find(entry => entry.model.definition.id === definition.id) + ?.pluginId ?? + c.get("plugin")?.id ?? + AUDIT_CORE_PLUGIN_ID; + +/** Whether `path` (or the group it belongs to) is one of `excluded`. */ +const isExcludedPath = (path: string, excluded: readonly string[]): boolean => + excluded.includes(path) || excluded.includes(path.split(".")[0]); + +/** + * The title an entry is labelled with: `admin.titleField` read off the row, + * when it is a non-empty string on this row. A localized title lives on the + * translation row, so a base-row write of a localized content type has none. + */ +export const contentAuditLabel = ( + definition: AnyContentTypeDefinition, + row: null | Record | undefined, +): null | string => { + const field = definition.admin.titleField; + if (!row || field === null) return null; + const value = + row[field] ?? (row.values as Record | undefined)?.[field]; + + return typeof value === "string" && value.trim() !== "" ? value : null; +}; + +/** + * {@link contentAuditLabel}, falling back to the default translation's title + * for a localized content type - read with `readDefaultTranslation`, inside the + * write's transaction (and before a delete, while it still exists). + */ +export const resolveContentAuditLabel = async ( + definition: AnyContentTypeDefinition, + row: null | Record | undefined, + readDefaultTranslation?: () => Promise, +): Promise => { + const own = contentAuditLabel(definition, row); + if ( + own !== null || + !readDefaultTranslation || + !definition.audit.enabled || + !definition.localization.enabled + ) { + return own; + } + + return contentAuditLabel( + definition, + (await readDefaultTranslation()) as null | Record, + ); +}; + +const valueAt = (row: Record, path: string): unknown => { + let value: unknown = row; + for (const segment of path.split(".")) { + if (value === null || typeof value !== "object") return null; + value = (value as Record)[segment]; + } + + return value ?? null; +}; + +/** + * Bounded before/after values of the scalar paths a plain (revisionless) + * write moved. Collections are named in `changedFields` only: a list of ids + * says little that the record itself does not. + */ +export const contentAuditChanges = ( + definition: AnyContentTypeDefinition, + { + after, + before, + changedFields, + scope = "shared", + }: { + after: Record; + before: Record; + changedFields: readonly string[]; + /** Which half of the fields the rows hold: the base row's or a translation's. */ + scope?: "localized" | "shared"; + }, +): AuditChanges | null => { + const audit = definition.audit; + if (!audit.trackChanges) return null; + + const { localizedFields, sharedFields } = partitionContentFields( + definition.fields, + ); + const scalars = scope === "localized" ? localizedFields : sharedFields; + const paths = changedFields.filter( + path => + !isExcludedPath(path, audit.excludeFields) && + scalars[path.split(".")[0]] !== undefined, + ); + if (paths.length === 0) return null; + + return auditDiff({ + after: Object.fromEntries(paths.map(path => [path, valueAt(after, path)])), + before: Object.fromEntries( + paths.map(path => [path, valueAt(before, path)]), + ), + }); +}; + +/** + * Writes the audit entry of one Content Engine operation **inside** its + * transaction, so the entry exists exactly when the write committed. Called by + * the editorial and translation revision captures and by the plain services - + * never by a post-commit event listener. A no-op when the content type has + * `audit: false`. + */ +export const recordContentAudit = async ( + c: Context, + tx: ContentDatabase, + { + action, + actor, + changedFields = [], + changes = null, + definition, + itemId, + label = null, + locale, + metadata, + pluginId, + revisionId = null, + }: ContentAuditWrite, +): Promise => { + const audit = definition.audit; + if (!audit.enabled) return; + + const fields = audit.trackChanges + ? changedFields.filter(path => !isExcludedPath(path, audit.excludeFields)) + : []; + const itemKey = String(itemId); + + await writeAuditLog( + c, + { + action: contentAuditAction(action), + actor, + ...(actor.type === "system" ? { area: "system" as const } : {}), + changes: audit.trackChanges ? changes : null, + contentTypeId: definition.id, + metadata: { + ...(fields.length > 0 ? { changedFields: fields } : {}), + ...(locale === undefined ? {} : { locale }), + ...metadata, + }, + pluginId, + revisionId, + target: { id: itemKey, label, type: definition.id }, + }, + tx, + ); +}; + +const isOwnEntry = ( + entry: AuditPendingEntry, + definition: AnyContentTypeDefinition, +): boolean => entry.contentTypeId === definition.id; + +/** + * Runs a duplicate so that it leaves one `content.duplicated` entry: the copy's + * own `created` entry is rewritten (it names the new record and the source), + * and the copied translations, created as part of the same operation, leave + * none of their own. Retried slug attempts roll their entry back with their + * savepoint, so a retry never doubles it. + */ +export const withContentDuplicateAudit = async ( + c: Context, + definition: AnyContentTypeDefinition, + sourceId: ContentId, + body: () => Promise, +): Promise => + await withAuditContext( + c, + { + intercept: entry => { + if (!isOwnEntry(entry, definition)) return entry; + if (entry.action === contentAuditAction("created")) { + return { + ...entry, + action: contentAuditAction("duplicated"), + metadata: { ...entry.metadata, sourceId }, + }; + } + if (entry.action === contentAuditAction("translation_created")) { + return null; + } + + return entry; + }, + }, + body, + ); + +/** + * Runs the translation cascade of a record publish or unpublish and holds back + * the per-language entries it would write, so the caller can fold them into + * the record's own entry (`metadata.locales`) - or write them after all when + * the record itself did not move. + */ +export const collectContentTranslationAudit = async ( + c: Context, + definition: AnyContentTypeDefinition, + body: () => Promise, +): Promise<{ entries: AuditPendingEntry[]; result: TResult }> => { + const entries: AuditPendingEntry[] = []; + const result = await withAuditContext( + c, + { + intercept: entry => { + if ( + !isOwnEntry(entry, definition) || + !entry.action.startsWith("content.translation_") + ) { + return entry; + } + entries.push(entry); + + return null; + }, + }, + body, + ); + + return { entries, result }; +}; + +/** Writes entries {@link collectContentTranslationAudit} held back. */ +export const writeContentAuditEntries = async ( + c: Context, + tx: ContentDatabase, + entries: readonly AuditPendingEntry[], +): Promise => { + for (const entry of entries) await writeAuditLog(c, entry, tx); +}; + +/** The locales held-back translation entries name, for the folded entry. */ +export const contentAuditLocales = ( + entries: readonly AuditPendingEntry[], +): string[] => + entries + .map(entry => entry.metadata.locale) + .filter((locale): locale is string => typeof locale === "string"); + +/** + * Runs one localized AdminCP save - the base row and any number of its + * translations, in `tx` - and writes its audit entries at the end of it: + * + * - a **create** leaves one `content.created` entry, labelled with the default + * translation's title and listing the languages it was created in; + * - an **update** leaves an entry per part that moved (the shared fields, each + * language), each labelled with the record's title even when only the + * translation carries it. + */ +export const withContentSaveAudit = async ( + c: Context, + definition: AnyContentTypeDefinition, + tx: ContentDatabase, + kind: "create" | "update", + body: () => Promise, +): Promise => { + const held: AuditPendingEntry[] = []; + const result = await withAuditContext( + c, + { + intercept: entry => { + if (!isOwnEntry(entry, definition)) return entry; + held.push(entry); + + return null; + }, + }, + body, + ); + + const defaultLocale = definition.localization.enabled + ? definition.localization.defaultLocale + : null; + const translations = held.filter( + entry => entry.metadata.locale !== undefined, + ); + // The title as the save left it: the default translation's, when this save + // wrote one - a shared-field entry read the title before the translations + // moved. + const label = + translations.find(entry => entry.metadata.locale === defaultLocale)?.target + .label ?? + held.find(entry => entry.target.label !== null)?.target.label ?? + null; + const labelled = (entry: AuditPendingEntry): AuditPendingEntry => ({ + ...entry, + target: { + ...entry.target, + label: + entry.metadata.locale === undefined + ? (label ?? entry.target.label) + : (entry.target.label ?? label), + }, + }); + + const created = held.find( + entry => entry.action === contentAuditAction("created"), + ); + if (kind === "create" && created) { + const folded = translations.filter( + entry => entry.action === contentAuditAction("translation_created"), + ); + const changedFields = [ + ...new Set( + [created, ...folded].flatMap(entry => + Array.isArray(entry.metadata.changedFields) + ? (entry.metadata.changedFields as unknown[]) + : [], + ), + ), + ]; + await writeAuditLog( + c, + labelled({ + ...created, + metadata: { + ...created.metadata, + ...(changedFields.length > 0 ? { changedFields } : {}), + locales: contentAuditLocales(folded), + }, + }), + tx, + ); + await writeContentAuditEntries( + c, + tx, + held + .filter(entry => entry !== created && !folded.includes(entry)) + .map(labelled), + ); + + return result; + } + + await writeContentAuditEntries(c, tx, held.map(labelled)); + + return result; +}; diff --git a/packages/vitnode/src/content/server/editorial-service.test.ts b/packages/vitnode/src/content/server/editorial-service.test.ts index 4e72792ed1..f80ca9ea44 100644 --- a/packages/vitnode/src/content/server/editorial-service.test.ts +++ b/packages/vitnode/src/content/server/editorial-service.test.ts @@ -2,6 +2,7 @@ import type { Context } from "hono"; import { describe, expect, it } from "vitest"; +import { createAuditRecorder } from "@/tests/audit-recorder"; import { testCategoryContentType, testEditorialNoteContentType, @@ -75,9 +76,10 @@ const createDbMock = ( return chain(queue.shift() ?? []); }; + const audit = createAuditRecorder(); const db = { delete: start("delete"), - insert: start("insert"), + insert: (table: unknown) => audit.insert(table) ?? start("insert")(table), select: start("select"), transaction: async ( body: (tx: unknown) => Promise, diff --git a/packages/vitnode/src/content/server/editorial-service.ts b/packages/vitnode/src/content/server/editorial-service.ts index 8b9c044460..d6b5b5f0ef 100644 --- a/packages/vitnode/src/content/server/editorial-service.ts +++ b/packages/vitnode/src/content/server/editorial-service.ts @@ -65,6 +65,15 @@ import { import { contentRelationStrategy, requireContentId } from "../ids"; import { partitionContentFields } from "../localization"; import { contentColumnsToValues, contentStorageColumns } from "../paths"; +import { + collectContentTranslationAudit, + CONTENT_REVISION_AUDIT_ACTIONS, + contentAuditLocales, + recordContentAudit, + resolveContentAuditLabel, + withContentDuplicateAudit, + writeContentAuditEntries, +} from "./audit"; import { buildContentRelationOperations, buildContentRepeatableOperations, @@ -524,10 +533,37 @@ export const createContentEditorialService = < return await c.get("db").transaction(async tx => await body(tx)); }; + /** + * The record's title for its audit entry. A localized title lives on the + * default translation, read in this transaction - before a delete, since the + * translations go with the row. + */ + const auditLabel = async ( + tx: ContentDatabase, + itemId: ContentId, + row: null | Record, + ): Promise => + await resolveContentAuditLabel( + definition, + row, + translations && definition.localization.enabled + ? async () => + await translations + .model() + .findByLocale( + itemId as ContentIdOf, + definition.localization.defaultLocale, + { tx }, + ) + : undefined, + ); + const capture = async ( tx: ContentDatabase, { actor, + auditLabel: knownLabel, + auditMetadata, changedFields, operation, restoredFromRevisionId, @@ -535,6 +571,9 @@ export const createContentEditorialService = < version, }: { actor: ContentActor; + /** Read before a delete, while the translations still exist. */ + auditLabel?: null | string; + auditMetadata?: Record; changedFields: readonly string[]; operation: ContentRevisionOperation; restoredFromRevisionId?: number; @@ -562,7 +601,7 @@ export const createContentEditorialService = < version, }); - return await revisions.capture(tx, { + const revisionId = await revisions.capture(tx, { actor, changedFields, // Pinned in the same statement batch as the revision: a snapshot naming a @@ -578,6 +617,29 @@ export const createContentEditorialService = < snapshot, version, }); + + // One revision, one audit entry, one transaction: the entry exists exactly + // when the write does. It points at the revision rather than copying the + // values, which the revision already holds. + await recordContentAudit(c, tx, { + action: CONTENT_REVISION_AUDIT_ACTIONS[operation], + actor, + changedFields, + definition, + itemId, + label: knownLabel ?? (await auditLabel(tx, itemId, row)), + metadata: { + version, + ...(restoredFromRevisionId === undefined + ? {} + : { restoredFromRevisionId }), + ...auditMetadata, + }, + pluginId, + revisionId, + }); + + return revisionId; }; /** @@ -710,6 +772,15 @@ export const createContentEditorialService = < .where(and(...conditions)) .returning(ownSelection()); + // Each language the record moves with it is part of this one operation, + // so its audit entry is held back and folded into the record's own. + const cascadeAudited = async () => + await collectContentTranslationAudit( + c, + definition, + async () => await cascade(tx, id, operation, options.actor), + ); + if (!row) { const current = await readOne(id, tx); if (!current) return null; @@ -735,7 +806,10 @@ export const createContentEditorialService = < // reconcile them: the transition is guarded on the base row, so it would // short-circuit here for ever. Publishing means "make this published", // and each language's own guard keeps it a no-op once it is. - const moved = await cascade(tx, id, operation, options.actor); + const { entries, result: moved } = await cascadeAudited(); + // The record did not move, so there is no entry to fold into: each + // language that did move is its own operation after all. + await writeContentAuditEntries(c, tx, entries); return { changed: false, @@ -765,11 +839,13 @@ export const createContentEditorialService = < // After the base row moves and before this record's own revision, so each // language's delivery reservation is taken against a record that is already // publicly visible: a localized address is only public when both halves are. - const moved = await cascade(tx, id, operation, options.actor); + const { entries, result: moved } = await cascadeAudited(); + const locales = contentAuditLocales(entries); const version = versionOf(row); const revisionId = await capture(tx, { actor: options.actor, + ...(locales.length > 0 ? { auditMetadata: { locales } } : {}), changedFields: [], operation, row, @@ -1076,6 +1152,10 @@ export const createContentEditorialService = < // child rows with the record, so this is the last moment the history can // record what the record was related to. const collections = (await store?.load(id, tx)) ?? {}; + // Likewise the title: a localized one goes with the translation rows. + const label = definition.localization.enabled + ? await auditLabel(tx, id, null) + : null; // Same guard as `guardedWrite`, in a `DELETE` - the version has to be // part of the statement that removes the row, not checked before it. @@ -1115,6 +1195,7 @@ export const createContentEditorialService = < const version = versionOf(row) + 1; const revisionId = await capture(tx, { actor: options.actor, + auditLabel: label, changedFields: [], operation: "delete", row: { ...row, __collections: collections }, @@ -1446,49 +1527,58 @@ export const createContentEditorialService = < const duplication: ContentEditorialDuplicationMethods = { duplicate: async (sourceId, options) => { - const result = await runContentDuplicate( - { - advanced: store, - c, - columns, - definition, - pluginId, - table, - translation: - translations as ContentDuplicateDependencies["translation"], - }, + const result = await withContentDuplicateAudit( + c, + definition, sourceId, - options, - { - base: async (values, tx) => - await editorial.create(values as ContentCreateInput, { - actor: options.actor, - tx, - }), - idOf: outcome => - requireContentId( - definition.idStrategy, - (outcome.row as { id: unknown }).id, - contentTypeId, - ), - translation: async (itemId, locale, values, tx) => { - if (!translations) { - throw new ContentEngineError( - "Duplicating a translation needs the translation editorial service.", - { contentTypeId }, - ); - } - - return await translations - .editorial() - .create( - itemId as ContentIdOf, - locale, - values as ContentLocalizedValues, - { actor: options.actor, tx }, - ); - }, - }, + async () => + await runContentDuplicate( + { + advanced: store, + c, + columns, + definition, + pluginId, + table, + translation: + translations as ContentDuplicateDependencies["translation"], + }, + sourceId, + options, + { + base: async (values, tx) => + await editorial.create( + values as ContentCreateInput, + { + actor: options.actor, + tx, + }, + ), + idOf: outcome => + requireContentId( + definition.idStrategy, + (outcome.row as { id: unknown }).id, + contentTypeId, + ), + translation: async (itemId, locale, values, tx) => { + if (!translations) { + throw new ContentEngineError( + "Duplicating a translation needs the translation editorial service.", + { contentTypeId }, + ); + } + + return await translations + .editorial() + .create( + itemId as ContentIdOf, + locale, + values as ContentLocalizedValues, + { actor: options.actor, tx }, + ); + }, + }, + ), ); return result diff --git a/packages/vitnode/src/content/server/localized-admin-routes.ts b/packages/vitnode/src/content/server/localized-admin-routes.ts index 30944367b9..764817e6ee 100644 --- a/packages/vitnode/src/content/server/localized-admin-routes.ts +++ b/packages/vitnode/src/content/server/localized-admin-routes.ts @@ -28,6 +28,7 @@ import { CONTENT_LOCALE_MAX_LENGTH, CONTENT_PERMISSIONS } from "../const"; import { zodContentFileReferenceRejection } from "../files"; import { requireContentId } from "../ids"; import { resolveContentActor } from "./actor"; +import { withContentSaveAudit } from "./audit"; import { contentEditorialEffects } from "./editorial-effects"; import { emitContentEvent } from "./emit"; import { contentFileFields } from "./files"; @@ -328,35 +329,44 @@ export const buildContentLocalizedAdminRoutes = < const result = await withHttpErrors( "create", async () => - await c.get("db").transaction(async tx => { - const outcome = editorial - ? await editorialService(c).create(body.values, { - actor: resolveContentActor(c), - tx, - }) - : null; - const row = - outcome?.row ?? - (await model.service(c).create(body.values, { tx })); - - const written: ContentTranslationEditorialOutcome[] = - []; - for (const entry of ordered) { - const translated = await writeTranslation( + await c.get("db").transaction( + async tx => + await withContentSaveAudit( c, - requireContentId( - definition.idStrategy, - (row as { id: unknown }).id, - definition.id, - ) as ContentIdOf, - entry, + definition, tx, - ); - if (translated) written.push(translated); - } - - return { outcome, row, written }; - }), + "create", + async () => { + const outcome = editorial + ? await editorialService(c).create(body.values, { + actor: resolveContentActor(c), + tx, + }) + : null; + const row = + outcome?.row ?? + (await model.service(c).create(body.values, { tx })); + + const written: ContentTranslationEditorialOutcome[] = + []; + for (const entry of ordered) { + const translated = await writeTranslation( + c, + requireContentId( + definition.idStrategy, + (row as { id: unknown }).id, + definition.id, + ) as ContentIdOf, + entry, + tx, + ); + if (translated) written.push(translated); + } + + return { outcome, row, written }; + }, + ), + ), { contentTypeId: definition.id, structured: editorial }, ); @@ -413,51 +423,63 @@ export const buildContentLocalizedAdminRoutes = < const result = await withHttpErrors( "update", async () => - await c.get("db").transaction(async tx => { - // Shared first, so a base version conflict costs nothing: the - // translations are never written and the transaction ends here. - let outcome: ContentEditorialOutcome | null = null; - let changedFields: readonly string[] = []; - let row: ContentSelect | null = null; - - if (shared !== undefined) { - if (editorial) { - outcome = await editorialService(c).update(id, shared, { - actor: resolveContentActor(c), - expectedVersion: body.expectedVersion ?? 0, - tx, - }); - if (!outcome) return null; - changedFields = outcome.changedFields; - row = outcome.row; - } else { - const plain = await model.service(c).update(id, shared, { tx }); - if (!plain) return null; - changedFields = plain.changedFields; - row = plain.row; - } - } - - const written: ContentTranslationEditorialOutcome[] = - []; - for (const entry of entries) { - const translated = await writeTranslation(c, id, entry, tx); - if (translated) written.push(translated); - } - - const current = - row ?? (await model.service(c).findById(id, { tx })); - - return current === null - ? null - : { - changedFields, - outcome, - row: current, - touched: row !== null, - written, - }; - }), + await c.get("db").transaction( + async tx => + await withContentSaveAudit( + c, + definition, + tx, + "update", + async () => { + // Shared first, so a base version conflict costs nothing: the + // translations are never written and the transaction ends here. + let outcome: ContentEditorialOutcome | null = + null; + let changedFields: readonly string[] = []; + let row: ContentSelect | null = null; + + if (shared !== undefined) { + if (editorial) { + outcome = await editorialService(c).update(id, shared, { + actor: resolveContentActor(c), + expectedVersion: body.expectedVersion ?? 0, + tx, + }); + if (!outcome) return null; + changedFields = outcome.changedFields; + row = outcome.row; + } else { + const plain = await model + .service(c) + .update(id, shared, { tx }); + if (!plain) return null; + changedFields = plain.changedFields; + row = plain.row; + } + } + + const written: ContentTranslationEditorialOutcome[] = + []; + for (const entry of entries) { + const translated = await writeTranslation(c, id, entry, tx); + if (translated) written.push(translated); + } + + const current = + row ?? (await model.service(c).findById(id, { tx })); + + return current === null + ? null + : { + changedFields, + outcome, + row: current, + touched: row !== null, + written, + }; + }, + ), + ), { contentTypeId: definition.id, itemId: id, structured: editorial }, ); diff --git a/packages/vitnode/src/content/server/localized-service.test.ts b/packages/vitnode/src/content/server/localized-service.test.ts index ac92cc09f8..17cb0327d6 100644 --- a/packages/vitnode/src/content/server/localized-service.test.ts +++ b/packages/vitnode/src/content/server/localized-service.test.ts @@ -2,6 +2,7 @@ import type { Context } from "hono"; import { describe, expect, it } from "vitest"; +import { createAuditRecorder } from "@/tests/audit-recorder"; import { testLocalizedArticleContentType, testPostContentType, @@ -99,9 +100,10 @@ const createDbMock = ( ); }; + const audit = createAuditRecorder(); const db = { delete: start("delete"), - insert: start("insert"), + insert: (table: unknown) => audit.insert(table) ?? start("insert")(table), select: start("select"), transaction: async ( body: (tx: unknown) => Promise, diff --git a/packages/vitnode/src/content/server/schedules-model.ts b/packages/vitnode/src/content/server/schedules-model.ts index 16350f6629..196a08c8f3 100644 --- a/packages/vitnode/src/content/server/schedules-model.ts +++ b/packages/vitnode/src/content/server/schedules-model.ts @@ -17,6 +17,8 @@ import { CONTENT_QUEUE_TASK_SCHEDULE, CONTENT_SCHEDULE_CODES } from "../const"; import { ContentScheduleError } from "../errors"; import { contentIdKey } from "../ids"; import { contentScheduleTimingError } from "../schedules"; +import { resolveContentActor } from "./actor"; +import { recordContentAudit } from "./audit"; /** A schedule row claimed for execution, with everything the handler needs. */ export interface ClaimedContentSchedule { @@ -170,24 +172,41 @@ export const createContentSchedulesModel = ({ .where(and(scope(itemId), eq(core_content_schedules.status, "pending"))); return { - cancel: async (itemId, scheduleId) => { - const [row] = await c - .get("db") - .update(core_content_schedules) - .set({ status: "cancelled" }) - .where( - and( - scope(itemId), - eq(core_content_schedules.id, scheduleId), - // Only a pending one can be cancelled. Re-cancelling a completed - // schedule would rewrite history to say it never ran. - eq(core_content_schedules.status, "pending"), - ), - ) - .returning({ action: core_content_schedules.action }); + cancel: async (itemId, scheduleId) => + await c.get("db").transaction(async tx => { + const [row] = await tx + .update(core_content_schedules) + .set({ status: "cancelled" }) + .where( + and( + scope(itemId), + eq(core_content_schedules.id, scheduleId), + // Only a pending one can be cancelled. Re-cancelling a completed + // schedule would rewrite history to say it never ran. + eq(core_content_schedules.status, "pending"), + ), + ) + .returning({ + action: core_content_schedules.action, + scheduledFor: core_content_schedules.scheduledFor, + }); + if (!row) return null; + + await recordContentAudit(c, tx, { + action: "schedule_cancelled", + actor: resolveContentActor(c), + definition, + itemId, + metadata: { + scheduleAction: row.action, + scheduledFor: row.scheduledFor, + scheduleId, + }, + pluginId, + }); - return row ?? null; - }, + return { action: row.action }; + }), listForItem: async itemId => { const rows = await c @@ -301,6 +320,20 @@ export const createContentSchedulesModel = ({ tx, }); + await recordContentAudit(c, tx, { + action: "scheduled", + actor: resolveContentActor(c), + definition, + itemId, + metadata: { + ...(previous ? { replacedPending: true } : {}), + scheduleAction: action, + scheduledFor, + scheduleId: row.id, + }, + pluginId, + }); + return { generation, id: row.id, scheduledFor }; }); }, diff --git a/packages/vitnode/src/content/server/service.test.ts b/packages/vitnode/src/content/server/service.test.ts index 3f537ab901..e61a0d21b0 100644 --- a/packages/vitnode/src/content/server/service.test.ts +++ b/packages/vitnode/src/content/server/service.test.ts @@ -6,6 +6,7 @@ import { PgDialect } from "drizzle-orm/pg-core"; import { describe, expect, it } from "vitest"; import { ZodError } from "zod"; +import { createAuditRecorder } from "@/tests/audit-recorder"; import { testArticleContentType, testCategoryContentType, @@ -86,10 +87,17 @@ const createDbMock = ( return chain(queue.shift() ?? []); }; + // Audit entries are kept apart from the scripted statements: they are + // written inside every audited write, and asserted on their own. + const audit = createAuditRecorder(); + const db = { delete: start("delete"), - insert: start("insert"), + insert: (table: unknown) => audit.insert(table) ?? start("insert")(table), select: start("select"), + transaction: async ( + body: (tx: unknown) => Promise, + ): Promise => await body(db), update: start("update"), }; @@ -97,7 +105,7 @@ const createDbMock = ( get: (key: string) => (key === "db" ? db : variables[key]), } as Context; - return { c, calls }; + return { audits: audit.entries, c, calls }; }; const opsOf = (calls: RecordedCall[], op: string) => @@ -252,6 +260,83 @@ describe("content service", () => { }); }); + describe("audit", () => { + const auditedNotes = createContentModel( + defineContentType({ + id: "test.audited-note", + tableName: "test_audited_notes", + fields: { + title: field.text({ required: true }), + body: field.textarea({ nullable: true }), + views: field.number({ integer: true, defaultValue: 0 }), + }, + audit: { excludeFields: ["views"] }, + admin: { titleField: "title" }, + }), + ); + + it("records content.updated with bounded before/after values, without excluded fields", async () => { + const long = "x".repeat(600); + const { audits, c } = createDbMock( + [ + [{ body: null, id: 7, title: "Old", views: 1 }], + [{ body: long, id: 7, title: "New", views: 2 }], + ], + { admin: { user: { id: 4, name: "Editor" } } }, + ); + + await auditedNotes + .service(c) + .update(7, { body: long, title: "New", views: 2 }); + + expect(audits).toEqual([ + expect.objectContaining({ + action: "content.updated", + actorId: 4, + actorType: "staff", + changes: { + body: { from: null, to: `${"x".repeat(500)}…` }, + title: { from: "Old", to: "New" }, + }, + contentTypeId: "test.audited-note", + metadata: { changedFields: ["title", "body"] }, + targetId: "7", + targetLabel: "New", + targetType: "test.audited-note", + }), + ]); + }); + + it("records nothing when nothing moved", async () => { + const { audits, c } = createDbMock([ + [{ body: null, id: 7, title: "Same", views: 1 }], + ]); + + await auditedNotes.service(c).update(7, { title: "Same" }); + + expect(audits).toEqual([]); + }); + + it("records nothing for a content type with audit: false", async () => { + const silent = createContentModel( + defineContentType({ + id: "test.silent", + tableName: "test_silent", + fields: { title: field.text({ required: true }) }, + audit: false, + }), + ); + const { audits, c } = createDbMock([ + [{ id: 7, title: "Old" }], + [{ id: 7, title: "New" }], + ]); + + await silent.service(c).update(7, { title: "New" }); + + expect(audits).toEqual([]); + }); + }); + describe("delete", () => { it("returns the deleted row", async () => { const { c } = createDbMock([[{ id: 7, title: "Hello" }]]); diff --git a/packages/vitnode/src/content/server/service.ts b/packages/vitnode/src/content/server/service.ts index d47fe615f5..275373235e 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,73 @@ 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 => { + const translationModel = translation?.model; + + return await resolveContentAuditLabel( + definition, + row, + translationModel + ? async () => + await translationModel().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 +816,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 +941,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 +979,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 +1068,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; - return row ? toRow(row) : 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 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 +1440,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 +1551,61 @@ export const createContentService = < const duplicationMethods: ContentDuplicationMethods = { duplicate: async (sourceId, options = {}) => { const translationModel = translation?.model; - const result = await runContentDuplicate( - { - advanced: store, - c, - columns, - definition, - table, - translation: - translation && translationModel - ? { - columns: translation.columns, - model: - translationModel as unknown as () => ContentDuplicateTranslationSource, - table: translation.table, - } - : undefined, - }, + const result = await withContentDuplicateAudit( + c, + definition, sourceId, - options, - { - base: async (values, tx) => - await service.create(values as ContentCreateInput, { - tx, - }), - idOf: row => - requireContentId( - definition.idStrategy, - (row as { id: unknown }).id, - contentTypeId, - ), - translation: async (itemId, locale, values, tx) => { - if (!translationModel) { - throw new ContentEngineError( - "Duplicating a translation needs the translation model.", - { contentTypeId }, - ); - } + async () => + await runContentDuplicate( + { + advanced: store, + c, + columns, + definition, + table, + translation: + translation && translationModel + ? { + columns: translation.columns, + model: + translationModel as unknown as () => ContentDuplicateTranslationSource, + table: translation.table, + } + : undefined, + }, + sourceId, + options, + { + base: async (values, tx) => + await service.create( + values as ContentCreateInput, + { + tx, + }, + ), + idOf: row => + requireContentId( + definition.idStrategy, + (row as { id: unknown }).id, + contentTypeId, + ), + translation: async (itemId, locale, values, tx) => { + if (!translationModel) { + throw new ContentEngineError( + "Duplicating a translation needs the translation model.", + { contentTypeId }, + ); + } - return await translationModel().create( - itemId as ContentIdOf, - locale, - values as ContentLocalizedValues, - { tx }, - ); - }, - }, + return await translationModel().create( + itemId as ContentIdOf, + locale, + values as ContentLocalizedValues, + { tx }, + ); + }, + }, + ), ); return result diff --git a/packages/vitnode/src/content/server/translation-editorial-service.ts b/packages/vitnode/src/content/server/translation-editorial-service.ts index 1a820774be..e1ce4b8988 100644 --- a/packages/vitnode/src/content/server/translation-editorial-service.ts +++ b/packages/vitnode/src/content/server/translation-editorial-service.ts @@ -36,6 +36,11 @@ import { contentInnerFields, splitContentFieldPath, } from "../paths"; +import { + CONTENT_TRANSLATION_REVISION_AUDIT_ACTIONS, + contentAuditLabel, + recordContentAudit, +} from "./audit"; import { applyContentDeliveryWrite, contentSlugHistoryFor, @@ -342,8 +347,8 @@ export const createContentTranslationEditorialService = < row: ContentTranslationRow; version: number; }, - ): Promise => - await revisionsFor(languageId).capture(tx, { + ): Promise => { + const revisionId = await revisionsFor(languageId).capture(tx, { actor, changedFields, itemId: row.itemId, @@ -357,6 +362,29 @@ export const createContentTranslationEditorialService = < version, }); + // In the transaction, beside the revision it points at - see the shared + // service's `capture`. + await recordContentAudit(c, tx, { + action: CONTENT_TRANSLATION_REVISION_AUDIT_ACTIONS[operation], + actor, + changedFields, + definition, + itemId: row.itemId, + label: contentAuditLabel(definition, row), + locale, + metadata: { + version, + ...(restoredFromRevisionId === undefined + ? {} + : { restoredFromRevisionId }), + }, + pluginId, + revisionId, + }); + + return revisionId; + }; + const unchanged = ( operation: ContentTranslationRevisionOperation, row: ContentTranslationRow, diff --git a/packages/vitnode/src/content/server/translation-model.ts b/packages/vitnode/src/content/server/translation-model.ts index 96b860a98e..0fa0336226 100644 --- a/packages/vitnode/src/content/server/translation-model.ts +++ b/packages/vitnode/src/content/server/translation-model.ts @@ -4,6 +4,7 @@ import type { Context } from "hono"; import { and, asc, eq, inArray, ne, sql } from "drizzle-orm"; +import type { AuditChanges } from "../../lib/audit/const"; import type { ContentId, ContentIdOf } from "../ids"; import type { ContentTranslationSchemas } from "../schemas"; import type { @@ -14,6 +15,7 @@ import type { ContentTranslationMeta, ContentTranslationRow, } from "../types"; +import type { ContentAuditAction } from "./audit"; import type { ContentLanguage } from "./language-resolver"; import type { ContentDatabase } from "./service"; @@ -34,6 +36,13 @@ import { contentStorageColumns, contentValuesToColumns, } from "../paths"; +import { resolveContentActor } from "./actor"; +import { + contentAuditChanges, + contentAuditLabel, + contentPluginIdOf, + recordContentAudit, +} from "./audit"; import { contentDatabase, findContentLanguage, @@ -430,7 +439,44 @@ export const createContentTranslationModel = < } }; - return { + // A translation of an editorial content type is audited by its revision + // capture; only a revisionless one is audited here. + const auditing = definition.audit.enabled && !definition.editorial.enabled; + + const audited = async ( + options: ContentTranslationOptions | undefined, + body: (tx: ContentDatabase) => Promise, + ): Promise => + options?.tx + ? await body(options.tx) + : await c.get("db").transaction(async tx => await body(tx)); + + const audit = async ( + tx: ContentDatabase, + action: ContentAuditAction, + row: ContentTranslationRow, + { + changedFields, + changes, + }: { + changedFields?: readonly string[]; + changes?: AuditChanges | null; + } = {}, + ): Promise => { + await recordContentAudit(c, tx, { + action, + actor: resolveContentActor(c), + changedFields, + changes, + definition, + itemId: row.itemId, + label: contentAuditLabel(definition, row), + locale: row.locale, + pluginId: contentPluginIdOf(c, definition), + }); + }; + + const methods: ContentTranslationModel = { create: async (itemId, locale, values, options) => { const target = await language(locale, { requireEnabled: true, @@ -786,14 +832,83 @@ export const createContentTranslationModel = < await touchItem(itemId, database); + const updated = toRow(row, target.locale); + if (auditing) { + await audit(database, "translation_updated", updated, { + changedFields, + changes: contentAuditChanges(definition, { + after: updated.values, + before: toRow(current, target.locale).values, + changedFields, + scope: "localized", + }), + }); + } + return { changed: true, changedFields, - row: toRow(row, target.locale), + row: updated, version: versionOf(row), }; }, }; + if (!auditing) return methods; + + return { + ...methods, + create: async (itemId, locale, values, options) => + await audited(options, async tx => { + const row = await methods.create(itemId, locale, values, { + ...options, + tx, + }); + await audit(tx, "translation_created", row, { + changedFields: Object.keys(row.values), + }); + + return row; + }), + delete: async (itemId, locale, options) => + await audited(options, async tx => { + const row = await methods.delete(itemId, locale, { ...options, tx }); + if (row) await audit(tx, "translation_deleted", row); + + return row; + }), + publish: async (itemId, locale, options) => + await audited(options, async tx => { + const result = await methods.publish(itemId, locale, { + ...options, + tx, + }); + if (result?.changed) { + await audit(tx, "translation_published", result.row); + } + + return result; + }), + unpublish: async (itemId, locale, options) => + await audited(options, async tx => { + const result = await methods.unpublish(itemId, locale, { + ...options, + tx, + }); + if (result?.changed) { + await audit(tx, "translation_unpublished", result.row); + } + + return result; + }), + // Audited where the row is read and written, so the entry costs no extra + // query; this only supplies the transaction. + update: async (itemId, locale, values, options) => + await audited( + options, + async tx => + await methods.update(itemId, locale, values, { ...options, tx }), + ), + }; }; /** The language registry keyed by id, for turning a stored FK back into a locale. */ diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts index 42bdd3c9f6..3b66f5dc7b 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -2035,6 +2035,33 @@ export interface ContentLeafColumn { path: string; } +/** + * How the Content Engine records a content type's writes in the central audit + * log. Present and on by default; `audit: false` turns it off. + */ +export interface ContentAuditInput { + /** + * Fields whose changes are never named or diffed in an audit entry - + * counters, cached totals, anything that changes on every write. The write + * itself is still recorded. Sensitive fields need no entry: they are always + * redacted. + */ + excludeFields?: readonly TFieldName[]; + /** + * Record which fields changed (and, without `editorial`, their bounded + * before/after values). Defaults to `true`; `false` records the operation + * only. + */ + trackChanges?: boolean; +} + +/** {@link ContentAuditInput} with every default applied. */ +export interface ResolvedContentAuditConfig { + enabled: boolean; + excludeFields: string[]; + trackChanges: boolean; +} + /** * A **resolved** content type, as returned by `defineContentType()`. * @@ -2067,6 +2094,8 @@ export interface ContentTypeDefinition< admin: ResolvedContentAdminConfig; /** Generated junction tables, child tables and the leaf-path mapping. */ advanced: ResolvedContentAdvancedConfig; + /** Automatic audit logging of the content type's writes. */ + audit: ResolvedContentAuditConfig; delivery: ResolvedContentDeliveryConfig; duplication: ResolvedContentDuplicationConfig; diff --git a/packages/vitnode/src/database/audit.ts b/packages/vitnode/src/database/audit.ts new file mode 100644 index 0000000000..ebd1217b6d --- /dev/null +++ b/packages/vitnode/src/database/audit.ts @@ -0,0 +1,99 @@ +import { camelCase, index } from "drizzle-orm/pg-core"; + +import type { AuditChanges } from "@/lib/audit/const"; + +import { + AUDIT_ACTION_MAX_LENGTH, + AUDIT_ACTOR_TYPES, + AUDIT_AREAS, + AUDIT_DEFAULT_RETENTION_DAYS, + AUDIT_PLUGIN_ID_MAX_LENGTH, + AUDIT_SCOPE_FAMILY_MAX_LENGTH, + AUDIT_SCOPE_ID_MAX_LENGTH, + AUDIT_TARGET_ID_MAX_LENGTH, + AUDIT_TARGET_LABEL_MAX_LENGTH, + AUDIT_TARGET_TYPE_MAX_LENGTH, +} from "@/lib/audit/const"; + +import { core_users } from "./users"; + +/** + * The append-only history of administrative, moderation, plugin and Content + * Engine operations - who did what, when, to which resource. + * + * Deliberately separate from `core_logs` (technical warnings and errors) and + * from `core_content_revisions` (what a record's content used to be). Nothing + * here cascades from the resources it describes: `pluginId`, `targetType`, + * `targetId` and `targetLabel` are plain text, so an entry outlives the record, + * the content type and the plugin it names. Only the actor is a foreign key, + * set to `null` when the account is deleted; `actorName` keeps the name the + * entry was written under. + */ +export const core_audit_logs = camelCase.table.withRLS( + "core_audit_logs", + t => ({ + id: t.bigserial({ mode: "number" }).primaryKey(), + pluginId: t.varchar({ length: AUDIT_PLUGIN_ID_MAX_LENGTH }).notNull(), + area: t.varchar({ enum: AUDIT_AREAS, length: 16 }).notNull(), + action: t.varchar({ length: AUDIT_ACTION_MAX_LENGTH }).notNull(), + actorType: t.varchar({ enum: AUDIT_ACTOR_TYPES, length: 16 }).notNull(), + actorId: t.integer().references(() => core_users.id, { + onDelete: "set null", + onUpdate: "cascade", + }), + actorName: t.varchar({ length: 255 }), + targetType: t.varchar({ length: AUDIT_TARGET_TYPE_MAX_LENGTH }).notNull(), + targetId: t.varchar({ length: AUDIT_TARGET_ID_MAX_LENGTH }), + targetLabel: t.varchar({ length: AUDIT_TARGET_LABEL_MAX_LENGTH }), + changes: t.jsonb().$type(), + metadata: t.jsonb().$type>().notNull().default({}), + contentTypeId: t.varchar({ length: 100 }), + /** Not a foreign key: revisions are pruned by their own retention. */ + revisionId: t.integer(), + scopeFamily: t.varchar({ length: AUDIT_SCOPE_FAMILY_MAX_LENGTH }), + scopeId: t.varchar({ length: AUDIT_SCOPE_ID_MAX_LENGTH }), + ipAddress: t.varchar({ length: 45 }), + /** One id per request or background run, shared by every entry it wrote. */ + correlationId: t.varchar({ length: 64 }), + createdAt: t.timestamp().notNull().defaultNow(), + }), + t => [ + // Newest first, date ranges and the retention sweep. + index("core_audit_logs_created_at_id_idx").on(t.createdAt, t.id), + index("core_audit_logs_actor_idx").on(t.actorId, t.createdAt), + index("core_audit_logs_plugin_idx").on(t.pluginId, t.createdAt), + index("core_audit_logs_action_idx").on(t.action, t.createdAt), + index("core_audit_logs_area_idx").on(t.area, t.createdAt), + // One entity's history. + index("core_audit_logs_target_idx").on( + t.pluginId, + t.targetType, + t.targetId, + t.createdAt, + ), + // A scoped moderator's view. + index("core_audit_logs_scope_idx").on( + t.scopeFamily, + t.scopeId, + t.createdAt, + ), + ], +); + +/** + * The Activity Logs settings, one row (`id = 1`). Absent until an admin first + * saves them, so every reader falls back to the defaults. + */ +export const core_audit_settings = camelCase.table.withRLS( + "core_audit_settings", + t => ({ + id: t.integer().primaryKey().default(1), + /** Days an entry is kept. `0` keeps everything. */ + retentionDays: t.integer().notNull().default(AUDIT_DEFAULT_RETENTION_DAYS), + updatedAt: t + .timestamp() + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }), +); diff --git a/packages/vitnode/src/database/relations.ts b/packages/vitnode/src/database/relations.ts index e996049ff1..8e635a0633 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"; @@ -25,6 +26,7 @@ import * as users from "./users"; export const coreSchema = { ...admins, ...ai, + ...audit, ...content, ...contentContainers, ...contentSubmissions, diff --git a/packages/vitnode/src/lib/audit/actions.test.ts b/packages/vitnode/src/lib/audit/actions.test.ts new file mode 100644 index 0000000000..44db39d838 --- /dev/null +++ b/packages/vitnode/src/lib/audit/actions.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { defineAuditActions } from "./actions"; +import { AUDIT_ACTION_MAX_LENGTH } from "./const"; + +describe("defineAuditActions", () => { + it("returns valid declarations unchanged", () => { + const actions = [ + { action: "topic.moved", targetType: "topic" }, + { action: "post.status_changed", targetType: "forum.post" }, + ] as const; + + expect(defineAuditActions(actions)).toBe(actions); + }); + + it.each([ + ["an uppercase action", [{ action: "Topic.Moved", targetType: "topic" }]], + ["a trailing dot", [{ action: "topic.", targetType: "topic" }]], + [ + "an action over the length limit", + [{ action: "a".repeat(AUDIT_ACTION_MAX_LENGTH + 1), targetType: "t" }], + ], + [ + "a target type with spaces", + [{ action: "topic.moved", targetType: "a b" }], + ], + [ + "a duplicate action", + [ + { action: "topic.moved", targetType: "topic" }, + { action: "topic.moved", targetType: "post" }, + ], + ], + ])("throws for %s", (_label, actions) => { + expect(() => defineAuditActions(actions)).toThrow(); + }); +}); diff --git a/packages/vitnode/src/lib/audit/actions.ts b/packages/vitnode/src/lib/audit/actions.ts new file mode 100644 index 0000000000..c9a0664183 --- /dev/null +++ b/packages/vitnode/src/lib/audit/actions.ts @@ -0,0 +1,148 @@ +import { + AUDIT_ACTION_MAX_LENGTH, + AUDIT_ACTION_PATTERN, + AUDIT_TARGET_TYPE_MAX_LENGTH, + 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 siteNotesAuditActions = defineAuditActions([ + * { action: "note.created", targetType: "note" }, + * { action: "note.deleted", targetType: "note" }, + * ]); + * ``` + */ +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 ( + targetType.length > AUDIT_TARGET_TYPE_MAX_LENGTH || + !AUDIT_TARGET_TYPE_PATTERN.test(targetType) + ) { + throw new Error( + `Audit target type "${targetType}" of "${action}" must be a lowercase identifier.`, + ); + } + if (seen.has(action)) { + throw new Error(`Audit action "${action}" is declared twice.`); + } + seen.add(action); + } + + return actions; +}; + +/** The Content Engine's actions, recorded under the content type's plugin. */ +export const CONTENT_AUDIT_ACTIONS = [ + "content.created", + "content.updated", + "content.deleted", + "content.duplicated", + "content.published", + "content.unpublished", + "content.hidden", + "content.unhidden", + "content.restored", + "content.scheduled", + "content.schedule_cancelled", + "content.translation_created", + "content.translation_updated", + "content.translation_deleted", + "content.translation_published", + "content.translation_unpublished", + "content.translation_restored", +] as const; + +/** Everything core records under `@vitnode/core`. */ +export const CORE_AUDIT_ACTIONS = defineAuditActions([ + { action: "role.created", targetType: "role" }, + { action: "role.updated", targetType: "role" }, + { action: "role.deleted", targetType: "role" }, + { action: "admin_permissions.created", targetType: "staff_entry" }, + { action: "admin_permissions.updated", targetType: "staff_entry" }, + { action: "admin_permissions.deleted", targetType: "staff_entry" }, + { action: "moderator_permissions.created", targetType: "staff_entry" }, + { action: "moderator_permissions.updated", targetType: "staff_entry" }, + { action: "moderator_permissions.deleted", targetType: "staff_entry" }, + { action: "user.updated", targetType: "user" }, + { action: "user.roles_changed", targetType: "user" }, + { action: "user.password_reset", targetType: "user" }, + { action: "user.email_verified", targetType: "user" }, + { action: "user.device_revoked", targetType: "user" }, + { action: "user.devices_revoked", targetType: "user" }, + { action: "settings.updated", targetType: "settings" }, + { action: "audit_settings.updated", targetType: "settings" }, + { action: "ai_access.role_updated", targetType: "role" }, + { action: "ai_access.user_override_saved", targetType: "user" }, + { action: "ai_access.user_override_removed", targetType: "user" }, + { action: "notifications.type_updated", targetType: "notification_type" }, + { action: "notifications.paused", targetType: "settings" }, + { action: "notifications.resumed", targetType: "settings" }, + { action: "notifications.emails_cancelled", targetType: "settings" }, + { action: "notifications.all_marked_read", targetType: "settings" }, + { action: "notifications.all_deleted", targetType: "settings" }, + { action: "notifications.member_preferences_reset", targetType: "settings" }, + { action: "report_reason.created", targetType: "report_reason" }, + { action: "report_reason.updated", targetType: "report_reason" }, + { action: "report_reason.deleted", targetType: "report_reason" }, + { action: "report_reason.reordered", targetType: "report_reason" }, + { action: "report.claimed", targetType: "report" }, + { action: "report.assigned", targetType: "report" }, + { action: "report.unassigned", targetType: "report" }, + { action: "report.note_added", targetType: "report" }, + { action: "report.note_edited", targetType: "report" }, + { action: "report.note_deleted", targetType: "report" }, + { action: "report.resolved", targetType: "report" }, + { action: "report.reopened", targetType: "report" }, + { action: "report.action_taken", targetType: "report" }, + { action: "submission.approved", targetType: "submission" }, + { action: "submission.rejected", targetType: "submission" }, + { action: "cron.run", targetType: "cron_job" }, + { action: "payment.created", targetType: "payment" }, + { action: "payment.processing", targetType: "payment" }, + { action: "payment.succeeded", targetType: "payment" }, + { action: "payment.failed", targetType: "payment" }, + { action: "payment.expired", targetType: "payment" }, + { action: "audit_logs.pruned", targetType: "settings" }, + { action: "audit_logs.exported", targetType: "settings" }, +] as const); diff --git a/packages/vitnode/src/lib/audit/const.ts b/packages/vitnode/src/lib/audit/const.ts new file mode 100644 index 0000000000..a811381de7 --- /dev/null +++ b/packages/vitnode/src/lib/audit/const.ts @@ -0,0 +1,109 @@ +/** + * Where an audited operation was performed: the AdminCP, the ModeratorCP (or a + * moderator tool on the public site), a member's own write on the public site, + * or a background task with nobody behind it. + */ +export const AUDIT_AREAS = ["admin", "moderator", "public", "system"] as const; + +export type AuditArea = (typeof AUDIT_AREAS)[number]; + +/** + * The two staff logs of the AdminCP: moderators' (the `moderator` area) and + * admins' (every other area, system and member activity included). + */ +export const AUDIT_STAFF_LOGS = ["admin", "moderator"] as const; + +export type AuditStaffLog = (typeof AUDIT_STAFF_LOGS)[number]; + +/** + * Who performed an audited operation: a staff member (admin or moderator), a + * signed-in member writing through the public API, or the system itself. The + * same vocabulary the Content Engine uses for its revisions. + */ +export const AUDIT_ACTOR_TYPES = ["staff", "api", "system"] as const; + +export type AuditActorType = (typeof AUDIT_ACTOR_TYPES)[number]; + +/** The plugin id core records its own operations under. */ +export const AUDIT_CORE_PLUGIN_ID = "@vitnode/core"; + +export const AUDIT_PLUGIN_ID_MAX_LENGTH = 255; +export const AUDIT_ACTION_MAX_LENGTH = 150; +export const AUDIT_TARGET_TYPE_MAX_LENGTH = 100; +export const AUDIT_TARGET_ID_MAX_LENGTH = 64; +export const AUDIT_TARGET_LABEL_MAX_LENGTH = 255; +export const AUDIT_SCOPE_FAMILY_MAX_LENGTH = 100; +export const AUDIT_SCOPE_ID_MAX_LENGTH = 64; + +/** + * Stable, lowercase, dot-separated identifiers: `category.updated`, + * `report.status_changed`, `content.translation_published`. + */ +export const AUDIT_ACTION_PATTERN = /^[a-z0-9_]+(?:\.[a-z0-9_]+)*$/; + +/** + * A target type uses the same alphabet: `category`, `report`, or a content + * type id such as `blog.category`. + */ +export const AUDIT_TARGET_TYPE_PATTERN = /^[a-z0-9_-]+(?:\.[a-z0-9_-]+)*$/; + +/** Longest string a change or metadata value keeps before it is cut. */ +export const AUDIT_STRING_MAX_LENGTH = 500; +/** Most fields one `changes` object keeps. */ +export const AUDIT_CHANGES_MAX_FIELDS = 50; +/** Most keys one metadata object (at any depth) keeps. */ +export const AUDIT_OBJECT_MAX_KEYS = 50; +/** Most items an array keeps. */ +export const AUDIT_ARRAY_MAX_ITEMS = 50; +/** How deep a nested value is followed before it is summarised. */ +export const AUDIT_MAX_DEPTH = 4; +/** Serialized budget of `changes`, and separately of `metadata`, in bytes. */ +export const AUDIT_JSON_MAX_BYTES = 16_384; + +/** What a value is replaced with when it must never be stored. */ +export const AUDIT_REDACTED = "[redacted]"; +/** What a value is replaced with when it is too large to be worth storing. */ +export const AUDIT_TRUNCATED = "[truncated]"; +/** What a rich-text document is stored as: the revision has the document. */ +export const AUDIT_RICH_TEXT = "[rich text]"; + +/** Retention choices offered in the AdminCP, in days. `0` keeps everything. */ +export const AUDIT_RETENTION_DAYS_OPTIONS = [0, 30, 90, 180, 365, 730] as const; +export const AUDIT_DEFAULT_RETENTION_DAYS = 365; +/** Rows the retention job deletes per statement, so a backlog never locks the table. */ +export const AUDIT_RETENTION_BATCH_SIZE = 5_000; +/** Most rows one CSV export contains. */ +export const AUDIT_EXPORT_MAX_ROWS = 10_000; + +/** A single before/after transition of one field. */ +export interface AuditChange { + from?: unknown; + /** + * `true` when the field is sensitive: the change is recorded, its values are + * not. + */ + redacted?: true; + to?: unknown; +} + +/** Structured before/after changes, keyed by field name. */ +export type AuditChanges = Record; + +/** The scope a moderation operation belongs to, as the moderation registry names it. */ +export interface AuditScope { + family: string; + id: number | string; +} + +/** The resource an audited operation acted on. */ +export interface AuditTarget { + /** Numeric and string ids are both stored as text. `null` for a singleton (settings). */ + id?: null | number | string; + /** + * A short human-readable name, kept so the entry still reads well after the + * resource is deleted. Not personal data beyond what the resource shows. + */ + label?: null | string; + /** `category`, `role`, `report`, `settings`... */ + type: string; +} diff --git a/packages/vitnode/src/lib/audit/csv.test.ts b/packages/vitnode/src/lib/audit/csv.test.ts new file mode 100644 index 0000000000..15d3424d07 --- /dev/null +++ b/packages/vitnode/src/lib/audit/csv.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { csvCell, toCsv } from "./csv"; + +describe("csvCell", () => { + it.each([ + ['=HYPERLINK("http://x")', `"'=HYPERLINK(""http://x"")"`], + ["+1", `"'+1"`], + ["-2", `"'-2"`], + ["@SUM(A1)", `"'@SUM(A1)"`], + ["\tcmd", `"'\tcmd"`], + ["\rcmd", `"'\rcmd"`], + ['say "hi"', `"say ""hi"""`], + ["plain", `"plain"`], + [null, `""`], + [undefined, `""`], + [42, `"42"`], + [false, `"false"`], + [new Date("2026-01-02T00:00:00.000Z"), `"2026-01-02T00:00:00.000Z"`], + [{ a: "b" }, `"{""a"":""b""}"`], + ])("%j -> %s", (value, cell) => { + expect(csvCell(value)).toBe(cell); + }); +}); + +describe("toCsv", () => { + it("writes the header first and joins rows with CRLF", () => { + expect( + toCsv( + ["id", "label"], + [ + [1, "A"], + [2, "=B"], + ], + ), + ).toBe(`"id","label"\r\n"1","A"\r\n"2","'=B"`); + }); +}); diff --git a/packages/vitnode/src/lib/audit/csv.ts b/packages/vitnode/src/lib/audit/csv.ts new file mode 100644 index 0000000000..06d0360cfa --- /dev/null +++ b/packages/vitnode/src/lib/audit/csv.ts @@ -0,0 +1,33 @@ +/** Characters a spreadsheet reads as the start of a formula. */ +const FORMULA_START = /^[=+\-@\t\r]/; + +/** + * One CSV cell: `null` and `undefined` are empty, objects are JSON, and a + * value a spreadsheet would run as a formula (`=HYPERLINK(...)`, `+1`, `@SUM`) + * is prefixed with `'` so it is shown, not evaluated. Always quoted. + */ +export const csvCell = (value: unknown): string => { + if (value === null || value === undefined) return '""'; + + let text: string; + if (value instanceof Date) text = value.toISOString(); + else if (typeof value === "string") text = value; + else if ( + typeof value === "number" || + typeof value === "boolean" || + typeof value === "bigint" + ) { + text = value.toString(); + } else text = JSON.stringify(value) ?? ""; + + if (FORMULA_START.test(text)) text = `'${text}`; + + return `"${text.replaceAll('"', '""')}"`; +}; + +/** A CSV document - header row first, CRLF line endings (RFC 4180). */ +export const toCsv = ( + header: readonly string[], + rows: readonly (readonly unknown[])[], +): string => + [header, ...rows].map(row => row.map(csvCell).join(",")).join("\r\n"); diff --git a/packages/vitnode/src/lib/audit/diff.test.ts b/packages/vitnode/src/lib/audit/diff.test.ts new file mode 100644 index 0000000000..f9665210bc --- /dev/null +++ b/packages/vitnode/src/lib/audit/diff.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; + +import { AUDIT_RICH_TEXT } from "./const"; +import { auditDiff } from "./diff"; + +const doc = (text: string) => ({ + content: [{ content: [{ text, type: "text" }], type: "paragraph" }], + type: "doc", +}); + +describe("auditDiff", () => { + it("leaves unchanged fields out and keeps transitions to and from null", () => { + expect( + auditDiff({ + after: { color: "#fff", name: "News", parentId: null }, + before: { color: null, name: "News", parentId: 3 }, + }), + ).toEqual({ + color: { from: null, to: "#fff" }, + parentId: { from: 3, to: null }, + }); + }); + + it("counts a field missing on one side as null", () => { + expect(auditDiff({ after: { name: "News" }, before: null })).toEqual({ + name: { from: null, to: "News" }, + }); + }); + + it("is null when nothing moved", () => { + expect( + auditDiff({ + after: { tags: [1, 2], updated: new Date("2026-01-01T00:00:00Z") }, + before: { tags: [1, 2], updated: new Date("2026-01-01T00:00:00Z") }, + }), + ).toBeNull(); + }); + + it("compares only the included fields and skips the excluded ones", () => { + const before = { name: "A", slug: "a", updatedAt: 1 }; + const after = { name: "B", slug: "b", updatedAt: 2 }; + + expect(auditDiff({ after, before, include: ["name"] })).toEqual({ + name: { from: "A", to: "B" }, + }); + expect(auditDiff({ after, before, exclude: ["updatedAt"] })).toEqual({ + name: { from: "A", to: "B" }, + slug: { from: "a", to: "b" }, + }); + }); + + it("records a sensitive field without its values", () => { + expect( + auditDiff({ + after: { passwordHash: "new" }, + before: { passwordHash: "old" }, + }), + ).toEqual({ passwordHash: { redacted: true } }); + }); + + it("stores a changed rich-text document as a marker, and an unchanged one not at all", () => { + expect( + auditDiff({ + after: { body: doc("new"), intro: doc("same") }, + before: { body: doc("old"), intro: doc("same") }, + }), + ).toEqual({ body: { from: AUDIT_RICH_TEXT, to: AUDIT_RICH_TEXT } }); + }); + + it("serializes dates in a recorded change", () => { + expect( + auditDiff({ + after: { publishedAt: new Date("2026-02-01T00:00:00.000Z") }, + before: { publishedAt: null }, + }), + ).toEqual({ + publishedAt: { from: null, to: "2026-02-01T00:00:00.000Z" }, + }); + }); +}); diff --git a/packages/vitnode/src/lib/audit/diff.ts b/packages/vitnode/src/lib/audit/diff.ts new file mode 100644 index 0000000000..6951b702e1 --- /dev/null +++ b/packages/vitnode/src/lib/audit/diff.ts @@ -0,0 +1,104 @@ +import type { AuditChanges } from "./const"; + +import { AUDIT_RICH_TEXT } from "./const"; +import { + isRichTextDocument, + 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 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)]), + ]; + + const changes: AuditChanges = {}; + for (const field of fields) { + if (excluded.has(field)) continue; + const from = left[field] ?? null; + const to = right[field] ?? null; + + if (isRichTextDocument(from) || isRichTextDocument(to)) { + if (JSON.stringify(from) !== JSON.stringify(to)) { + changes[field] = { from: AUDIT_RICH_TEXT, to: AUDIT_RICH_TEXT }; + } + continue; + } + if (sameValue(from, to)) continue; + + changes[field] = isSensitiveAuditKey(field) + ? { redacted: true } + : { from, to }; + } + + return sanitizeAuditChanges(changes); +}; diff --git a/packages/vitnode/src/lib/audit/sanitize.test.ts b/packages/vitnode/src/lib/audit/sanitize.test.ts new file mode 100644 index 0000000000..59239bb9c1 --- /dev/null +++ b/packages/vitnode/src/lib/audit/sanitize.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from "vitest"; + +import { + AUDIT_ARRAY_MAX_ITEMS, + AUDIT_CHANGES_MAX_FIELDS, + AUDIT_MAX_DEPTH, + AUDIT_OBJECT_MAX_KEYS, + AUDIT_REDACTED, + AUDIT_RICH_TEXT, + AUDIT_STRING_MAX_LENGTH, + AUDIT_TRUNCATED, +} from "./const"; +import { + isSensitiveAuditKey, + sanitizeAuditChanges, + sanitizeAuditMetadata, + sanitizeAuditValue, +} from "./sanitize"; + +const nested = (depth: number): unknown => + depth === 0 ? "leaf" : { child: nested(depth - 1) }; + +describe("isSensitiveAuditKey", () => { + it.each([ + ["accessToken", true], + ["client_secret", true], + ["passwordHash", true], + ["apiKey", true], + ["recoveryCodes", true], + ["REFRESH-TOKEN", true], + ["sessionId", true], + ["Authorization", true], + ["maxTokens", false], + ["keyboard", false], + ["name", false], + ["sessionCount", false], + ["apiVersion", false], + ])("%s -> %s", (key, sensitive) => { + expect(isSensitiveAuditKey(key)).toBe(sensitive); + }); +}); + +describe("sanitizeAuditValue", () => { + it("cuts a long string", () => { + const value = sanitizeAuditValue("x".repeat(AUDIT_STRING_MAX_LENGTH + 10)); + + expect(value).toBe(`${"x".repeat(AUDIT_STRING_MAX_LENGTH)}…`); + }); + + it("summarises a value nested past the depth limit", () => { + let value = sanitizeAuditValue(nested(AUDIT_MAX_DEPTH + 2)); + for (let depth = 0; depth < AUDIT_MAX_DEPTH; depth++) { + value = (value as { child: unknown }).child; + } + + expect(value).toBe(AUDIT_TRUNCATED); + }); + + it("keeps only an error's name", () => { + const error = new TypeError("postgres://user:hunter2@db/prod"); + + expect(sanitizeAuditValue(error)).toEqual({ error: "TypeError" }); + }); + + it("stores a rich-text document as a marker", () => { + expect( + sanitizeAuditValue({ content: [{ type: "paragraph" }], type: "doc" }), + ).toBe(AUDIT_RICH_TEXT); + }); + + it("drops class instances, functions and symbols", () => { + class Connection { + password = "hunter2"; + } + + expect( + sanitizeAuditValue({ + connection: new Connection(), + fn: () => 1, + kept: 1, + symbol: Symbol("x"), + }), + ).toEqual({ kept: 1 }); + }); + + it("redacts sensitive keys at any depth", () => { + expect( + sanitizeAuditValue({ + name: "Ann", + provider: { clientSecret: "abc", id: "github" }, + }), + ).toEqual({ + name: "Ann", + provider: { clientSecret: AUDIT_REDACTED, id: "github" }, + }); + }); + + it.each([ + [Number.NaN, null], + [Number.POSITIVE_INFINITY, null], + [10n, "10"], + [new Date("2026-01-02T03:04:05.000Z"), "2026-01-02T03:04:05.000Z"], + [new Date("not a date"), null], + ])("turns %s into %s", (input, output) => { + expect(sanitizeAuditValue(input)).toEqual(output); + }); + + it("cuts long arrays and wide objects with a marker", () => { + const array = sanitizeAuditValue( + Array.from({ length: AUDIT_ARRAY_MAX_ITEMS + 5 }, (_, index) => index), + ) as unknown[]; + const object = sanitizeAuditValue( + Object.fromEntries( + Array.from({ length: AUDIT_OBJECT_MAX_KEYS + 5 }, (_, index) => [ + `k${index}`, + index, + ]), + ), + ) as Record; + + expect(array).toHaveLength(AUDIT_ARRAY_MAX_ITEMS + 1); + expect(array.at(-1)).toBe(AUDIT_TRUNCATED); + expect(Object.keys(object)).toHaveLength(AUDIT_OBJECT_MAX_KEYS + 1); + expect(object[AUDIT_TRUNCATED]).toBe(true); + }); +}); + +describe("sanitizeAuditMetadata", () => { + it("replaces metadata over the size budget with a marker", () => { + const metadata = Object.fromEntries( + Array.from({ length: 40 }, (_, index) => [ + `field${index}`, + "x".repeat(AUDIT_STRING_MAX_LENGTH), + ]), + ); + + expect(sanitizeAuditMetadata(metadata)).toEqual({ + [AUDIT_TRUNCATED]: true, + }); + }); + + it.each([null, undefined])("is empty for %s", metadata => { + expect(sanitizeAuditMetadata(metadata)).toEqual({}); + }); +}); + +describe("sanitizeAuditChanges", () => { + it("keeps only the fact that a sensitive or flagged field changed", () => { + expect( + sanitizeAuditChanges({ + name: { from: "a", to: "b" }, + password: { from: "old", to: "new" }, + webhook: { from: "x", redacted: true, to: "y" }, + }), + ).toEqual({ + name: { from: "a", to: "b" }, + password: { redacted: true }, + webhook: { redacted: true }, + }); + }); + + it("keeps at most the field limit", () => { + const changes = Object.fromEntries( + Array.from({ length: AUDIT_CHANGES_MAX_FIELDS + 5 }, (_, index) => [ + `f${index}`, + { from: index, to: index + 1 }, + ]), + ); + + expect(Object.keys(sanitizeAuditChanges(changes) ?? {})).toHaveLength( + AUDIT_CHANGES_MAX_FIELDS, + ); + }); + + it("drops the values but keeps the field names over the size budget", () => { + const long = "x".repeat(AUDIT_STRING_MAX_LENGTH); + const changes = Object.fromEntries( + Array.from({ length: 20 }, (_, index) => [ + `f${index}`, + { from: long, to: long }, + ]), + ); + + const result = sanitizeAuditChanges({ + ...changes, + token: { from: "a", to: "b" }, + }); + + expect(result?.f0).toEqual({ from: AUDIT_TRUNCATED, to: AUDIT_TRUNCATED }); + expect(result?.token).toEqual({ redacted: true }); + expect(Object.keys(result ?? {})).toHaveLength(21); + }); + + it.each([null, undefined, {}])("is null for %s", changes => { + expect(sanitizeAuditChanges(changes)).toBeNull(); + }); +}); diff --git a/packages/vitnode/src/lib/audit/sanitize.ts b/packages/vitnode/src/lib/audit/sanitize.ts new file mode 100644 index 0000000000..5f72403b7b --- /dev/null +++ b/packages/vitnode/src/lib/audit/sanitize.ts @@ -0,0 +1,212 @@ +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; + }); +}; + +/** Whether a value is a rich-text (TipTap) document: `{ type: "doc", content: [...] }`. */ +export const isRichTextDocument = (value: unknown): boolean => + typeof value === "object" && + value !== null && + (value as { type?: unknown }).type === "doc" && + Array.isArray((value as { content?: unknown }).content); + +const truncateString = (value: string): string => + value.length > AUDIT_STRING_MAX_LENGTH + ? `${value.slice(0, AUDIT_STRING_MAX_LENGTH)}…` + : value; + +const isPlainObject = (value: object): value is Record => { + const prototype = Object.getPrototypeOf(value) as unknown; + + return prototype === Object.prototype || prototype === null; +}; + +/** + * Turns any value into a bounded, JSON-safe one that is safe to store: + * sensitive keys are replaced with `[redacted]`, long strings and collections + * are cut, rich-text documents become a marker (the revision keeps the + * document), errors keep their name only, and anything that is not data + * (functions, symbols, class instances) is dropped. Universal. + */ +export const sanitizeAuditValue = (value: unknown, depth = 0): unknown => { + if (value === null) return null; + + switch (typeof value) { + case "bigint": + return value.toString(); + case "boolean": + return value; + case "number": + return Number.isFinite(value) ? value : null; + case "object": + break; + case "string": + return truncateString(value); + default: + return undefined; + } + + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? null : value.toISOString(); + } + // An error's message is where connection strings and tokens end up, so only + // its class survives. + if (value instanceof Error) return { error: value.name }; + if (depth >= AUDIT_MAX_DEPTH) return AUDIT_TRUNCATED; + + if (Array.isArray(value)) { + const items = value + .slice(0, AUDIT_ARRAY_MAX_ITEMS) + .map(item => sanitizeAuditValue(item, depth + 1) ?? null); + + return value.length > AUDIT_ARRAY_MAX_ITEMS + ? [...items, AUDIT_TRUNCATED] + : items; + } + + if (isRichTextDocument(value)) return AUDIT_RICH_TEXT; + if (!isPlainObject(value)) return undefined; + + const result: Record = {}; + const entries = Object.entries(value); + for (const [key, entry] of entries.slice(0, AUDIT_OBJECT_MAX_KEYS)) { + if (isSensitiveAuditKey(key)) { + result[key] = AUDIT_REDACTED; + continue; + } + const sanitized = sanitizeAuditValue(entry, depth + 1); + if (sanitized !== undefined) result[key] = sanitized; + } + if (entries.length > AUDIT_OBJECT_MAX_KEYS) result[AUDIT_TRUNCATED] = true; + + return result; +}; + +const byteLength = (value: unknown): number => + new TextEncoder().encode(JSON.stringify(value)).byteLength; + +/** + * Sanitizes free-form metadata with {@link sanitizeAuditValue} and enforces + * the serialized size budget: metadata over it is replaced with + * `{ "[truncated]": true }` rather than half-stored. + */ +export const sanitizeAuditMetadata = ( + metadata: null | Record | undefined, +): Record => { + if (!metadata) return {}; + const sanitized = sanitizeAuditValue(metadata); + if (!sanitized || typeof sanitized !== "object" || Array.isArray(sanitized)) { + return {}; + } + if (byteLength(sanitized) > AUDIT_JSON_MAX_BYTES) { + return { [AUDIT_TRUNCATED]: true }; + } + + return sanitized as Record; +}; + +/** + * Sanitizes structured changes: a sensitive field keeps only the fact that it + * changed (`{ redacted: true }`), every value goes through + * {@link sanitizeAuditValue}, at most {@link AUDIT_CHANGES_MAX_FIELDS} fields + * are kept, and when the result is still over the size budget the values are + * dropped while the field names stay. `null` when nothing is left. + */ +export const sanitizeAuditChanges = ( + changes: AuditChanges | null | undefined, +): AuditChanges | null => { + if (!changes) return null; + + const result: AuditChanges = {}; + for (const [field, change] of Object.entries(changes).slice( + 0, + AUDIT_CHANGES_MAX_FIELDS, + )) { + if (isSensitiveAuditKey(field) || change.redacted) { + result[field] = { redacted: true }; + continue; + } + + const entry: AuditChange = {}; + if ("from" in change) entry.from = sanitizeAuditValue(change.from) ?? null; + if ("to" in change) entry.to = sanitizeAuditValue(change.to) ?? null; + result[field] = entry; + } + + if (Object.keys(result).length === 0) return null; + if (byteLength(result) <= AUDIT_JSON_MAX_BYTES) return result; + + return Object.fromEntries( + Object.entries(result).map(([field, change]) => [ + field, + change.redacted ? change : { from: AUDIT_TRUNCATED, to: AUDIT_TRUNCATED }, + ]), + ); +}; diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index e35d51be5d..0a663cb3ec 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -47,6 +47,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 technical details", + "@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", @@ -1760,7 +1765,8 @@ "delete": "Delete", "duplicate": "Duplicate", "hide": "Hide", - "unhide": "Unhide" + "unhide": "Unhide", + "activity": "Activity" }, "create": { "title": "Create {name}", @@ -2850,6 +2856,208 @@ } } } + }, + "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": "Could not load activity", + "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, value not recorded", + "revision": "Revision", + "revision_version": "Version {version}", + "revision_unavailable": "This revision 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" + }, + "payment": { + "created": "Started a payment", + "processing": "Payment awaiting confirmation", + "succeeded": "Payment succeeded", + "failed": "Payment failed", + "expired": "Payment expired" + } + }, + "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", + "amount": "Amount (minor units)", + "currency": "Currency", + "plugin": "Plugin", + "provider": "Provider", + "reference": "Reference", + "customerId": "Customer id", + "errorCode": "Error code" + } } }, "admin": { @@ -3692,6 +3900,31 @@ "title": "No administrators found", "description": "Assign a role or user to grant administrator permissions." } + }, + "logs": { + "open": "Logs", + "title": { + "admin": "Admin logs", + "moderator": "Moderator logs" + }, + "desc": { + "admin": "Who changed what in the AdminCP, plus what plugins, members and scheduled jobs did. Newest first.", + "moderator": "What moderators did: reports, approvals and content they hid or restored." + }, + "export": "Export CSV", + "export_error": "Could not download the export. Try again in a moment.", + "settings": { + "title": "Retention", + "open": "Retention settings", + "desc": "Applies to both staff logs. A daily job deletes entries older than this. A shorter period keeps less personal data around, a longer one keeps more history.", + "retention": "Keep entries for", + "forever": "Forever", + "days": "{count, plural, one {# day} other {# days}}", + "saved": "Retention saved", + "saved_desc": "The next daily run deletes entries older than {period}.", + "saved_forever": "Entries stay until you change this.", + "error": "Could not save the retention period." + } } }, "moderation": { diff --git a/packages/vitnode/src/pages/admin/staff/admins/logs.tsx b/packages/vitnode/src/pages/admin/staff/admins/logs.tsx new file mode 100644 index 0000000000..4d173e40da --- /dev/null +++ b/packages/vitnode/src/pages/admin/staff/admins/logs.tsx @@ -0,0 +1,39 @@ +import type { PluginRoutePageProps } from "@/routing"; +import type { AdminStaffLogsRouteData } from "@/tanstack/admin/staff/logs-route"; +import type { ActivityRouteSearch } from "@/views/activity-logs/activity-search"; + +import { AdminStaffLogsBreadcrumbContent } from "@/tanstack/admin/staff/breadcrumbs"; +import { loadAdminStaffLogsRoute } from "@/tanstack/admin/staff/logs-route"; +import { AdminStaffLogsRouteContent } from "@/tanstack/admin/staff/logs-screen"; +import { + defineAdminRoute, + routeBreadcrumbGroup, +} from "@/tanstack/plugin-routes"; + +const AdminsLogsPage = ({ + loaderData, + navigate, + search, +}: PluginRoutePageProps) => ( + +); + +export const route = defineAdminRoute< + AdminStaffLogsRouteData, + ActivityRouteSearch +>({ + // `head` after `load`, always. + load: async ({ context, search, t }) => + await loadAdminStaffLogsRoute({ ...context, search, t, type: "admin" }), + head: ({ loaderData }) => ({ ...loaderData }), + + breadcrumb: routeBreadcrumbGroup(function AdminsLogsBreadcrumb() { + return ; + }), +}); + +export default AdminsLogsPage; diff --git a/packages/vitnode/src/pages/admin/staff/moderators/logs.tsx b/packages/vitnode/src/pages/admin/staff/moderators/logs.tsx new file mode 100644 index 0000000000..79f953bd5a --- /dev/null +++ b/packages/vitnode/src/pages/admin/staff/moderators/logs.tsx @@ -0,0 +1,39 @@ +import type { PluginRoutePageProps } from "@/routing"; +import type { AdminStaffLogsRouteData } from "@/tanstack/admin/staff/logs-route"; +import type { ActivityRouteSearch } from "@/views/activity-logs/activity-search"; + +import { AdminStaffLogsBreadcrumbContent } from "@/tanstack/admin/staff/breadcrumbs"; +import { loadAdminStaffLogsRoute } from "@/tanstack/admin/staff/logs-route"; +import { AdminStaffLogsRouteContent } from "@/tanstack/admin/staff/logs-screen"; +import { + defineAdminRoute, + routeBreadcrumbGroup, +} from "@/tanstack/plugin-routes"; + +const ModeratorsLogsPage = ({ + loaderData, + navigate, + search, +}: PluginRoutePageProps) => ( + +); + +export const route = defineAdminRoute< + AdminStaffLogsRouteData, + ActivityRouteSearch +>({ + // `head` after `load`, always. + load: async ({ context, search, t }) => + await loadAdminStaffLogsRoute({ ...context, search, t, type: "moderator" }), + head: ({ loaderData }) => ({ ...loaderData }), + + breadcrumb: routeBreadcrumbGroup(function ModeratorsLogsBreadcrumb() { + return ; + }), +}); + +export default ModeratorsLogsPage; diff --git a/packages/vitnode/src/routes.tsx b/packages/vitnode/src/routes.tsx index 6a74580d85..9c56c1f056 100644 --- a/packages/vitnode/src/routes.tsx +++ b/packages/vitnode/src/routes.tsx @@ -35,6 +35,7 @@ import { ADMIN_SIGN_IN_NAMESPACES } from "./tanstack/admin/sign-in-route"; import { normalizeAdminSignInSearch } from "./tanstack/admin/sign-in-search"; import { ADMIN_STAFF_CREATE_NAMESPACES } from "./tanstack/admin/staff/create-route"; import { ADMIN_STAFF_EDIT_NAMESPACES } from "./tanstack/admin/staff/edit-route"; +import { ADMIN_STAFF_LOGS_NAMESPACES } from "./tanstack/admin/staff/logs-route"; import { ADMIN_STAFF_NAMESPACES } from "./tanstack/admin/staff/route"; import { normalizeStaffRouteSearch } from "./tanstack/admin/staff/route-search"; import { ADMIN_USER_NAMESPACES } from "./tanstack/admin/users/detail-route"; @@ -84,6 +85,7 @@ import { import { normalizeSearchRouteSearch } from "./tanstack/search/route-search"; import { SETTINGS_NAMESPACES } from "./tanstack/settings/route"; import { normalizeSsoSettingsSearch } from "./tanstack/sso-connections/route-search"; +import { normalizeActivitySearch } from "./views/activity-logs/activity-search"; /** * Every URL `@vitnode/core` owns, as the same kind of declaration a plugin @@ -414,6 +416,14 @@ export const routes = defineRoutes([ pendingComponent: FormPendingSkeleton, }), + page("/admin/core/staff/admins/logs", { + area: "admin", + component: lazy(() => import("./pages/admin/staff/admins/logs")), + messages: ADMIN_STAFF_LOGS_NAMESPACES, + pendingComponent: TablePendingSkeleton, + search: normalizeActivitySearch, + }), + page("/admin/core/staff/moderators", { area: "admin", component: lazy(() => import("./pages/admin/staff/moderators/index")), @@ -436,6 +446,14 @@ export const routes = defineRoutes([ pendingComponent: FormPendingSkeleton, }), + page("/admin/core/staff/moderators/logs", { + area: "admin", + component: lazy(() => import("./pages/admin/staff/moderators/logs")), + messages: ADMIN_STAFF_LOGS_NAMESPACES, + pendingComponent: TablePendingSkeleton, + search: normalizeActivitySearch, + }), + page("/admin/content/*", { area: "admin", component: lazy(() => import("./pages/admin/content")), diff --git a/packages/vitnode/src/tanstack/admin/content/editorial/panels.tsx b/packages/vitnode/src/tanstack/admin/content/editorial/panels.tsx index 21811bcd67..d6d6e98ed6 100644 --- a/packages/vitnode/src/tanstack/admin/content/editorial/panels.tsx +++ b/packages/vitnode/src/tanstack/admin/content/editorial/panels.tsx @@ -1,5 +1,6 @@ import React from "react"; +import { ActivityContentPanel } from "@/views/admin/views/content/actions/activity-action"; import { DeliveryContentPanel } from "@/views/admin/views/content/actions/delivery-action"; import { contentDeliveryRequestLocale } from "@/views/admin/views/content/actions/delivery-model"; import { HistoryContentPanel } from "@/views/admin/views/content/actions/history-action"; @@ -8,6 +9,7 @@ import { ScheduleContentPanel } from "@/views/admin/views/content/actions/schedu import type { ContentRowPanelProps } from "../slots"; +import { RouteMessages } from "../../../i18n/route-messages"; import { useContentTypeForm } from "../form/spec"; import { ContentEditorialHost } from "./host"; @@ -112,7 +114,39 @@ const DeliveryPanel = ({ ); +/** + * The record's audit trail - for every content type, editorial or not. Its own + * messages, because the content screen does not load the Activity Logs ones. + */ +const ActivityPanel = ({ + entry, + finalFocus, + itemId, + onOpenChange, + open, + singular, +}: ContentRowPanelProps) => ( + + + +); + export const contentEditorialRowPanels = { + activity: ActivityPanel, delivery: DeliveryPanel, history: HistoryPanel, preview: PreviewPanel, diff --git a/packages/vitnode/src/tanstack/admin/content/row-actions.tsx b/packages/vitnode/src/tanstack/admin/content/row-actions.tsx index 6b2c1c3b20..dbe637c591 100644 --- a/packages/vitnode/src/tanstack/admin/content/row-actions.tsx +++ b/packages/vitnode/src/tanstack/admin/content/row-actions.tsx @@ -1,6 +1,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import { + ActivityIcon, CalendarClockIcon, CopyPlusIcon, EllipsisIcon, @@ -298,6 +299,7 @@ const ACTION_ICONS: Record< Exclude, React.ReactNode > = { + activity: , delete: , delivery: , duplicate: , diff --git a/packages/vitnode/src/tanstack/admin/staff/breadcrumbs.tsx b/packages/vitnode/src/tanstack/admin/staff/breadcrumbs.tsx index 5b61cf3f92..d7896bfa7c 100644 --- a/packages/vitnode/src/tanstack/admin/staff/breadcrumbs.tsx +++ b/packages/vitnode/src/tanstack/admin/staff/breadcrumbs.tsx @@ -64,6 +64,19 @@ const StaffEditCrumb = ({ type }: StaffBreadcrumbProps) => { ); }; +const StaffLogsCrumb = ({ type }: StaffBreadcrumbProps) => { + const labels = useStaffLabels(type); + const t = useTranslations("admin.staff.logs"); + + return ( + + ); +}; + export const AdminStaffBreadcrumbContent = (props: StaffBreadcrumbProps) => ( @@ -85,3 +98,11 @@ export const AdminStaffEditBreadcrumbContent = ( ); + +export const AdminStaffLogsBreadcrumbContent = ( + props: StaffBreadcrumbProps, +) => ( + + + +); diff --git a/packages/vitnode/src/tanstack/admin/staff/index.ts b/packages/vitnode/src/tanstack/admin/staff/index.ts index a27d890ccd..95bfb120f9 100644 --- a/packages/vitnode/src/tanstack/admin/staff/index.ts +++ b/packages/vitnode/src/tanstack/admin/staff/index.ts @@ -3,6 +3,7 @@ export { AdminStaffBreadcrumbContent, AdminStaffCreateBreadcrumbContent, AdminStaffEditBreadcrumbContent, + AdminStaffLogsBreadcrumbContent, } from "./breadcrumbs"; export type { AdminStaffCreateRouteData } from "./create-route"; @@ -23,6 +24,13 @@ export { } from "./edit-route"; export type { AdminStaffEditRouteProps } from "./edit-screen"; export { AdminStaffEditRouteContent } from "./edit-screen"; +export type { AdminStaffLogsRouteData } from "./logs-route"; +export { + ADMIN_STAFF_LOGS_NAMESPACES, + loadAdminStaffLogsRoute, +} from "./logs-route"; +export type { AdminStaffLogsRouteProps } from "./logs-screen"; +export { AdminStaffLogsRouteContent } from "./logs-screen"; export { adminStaffCatalogQuery, adminStaffEntryQuery, @@ -59,6 +67,7 @@ export { staffCreateHref, staffEditHref, staffListHref, + staffLogsHref, staffPermissionsForSubmit, staffTypeFromSegment, } from "@/views/admin/views/core/staff/staff-model"; diff --git a/packages/vitnode/src/tanstack/admin/staff/logs-route.tsx b/packages/vitnode/src/tanstack/admin/staff/logs-route.tsx new file mode 100644 index 0000000000..fc272119d3 --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/staff/logs-route.tsx @@ -0,0 +1,69 @@ +import type { PermissionStaffType } from "@/api/lib/permission-staff"; +import type { PluginRouteTranslator } from "@/routing"; +import type { ActivityRouteSearch } from "@/views/activity-logs/activity-search"; + +import { + activityFilterOptionsQueryOptions, + activityListQueryOptions, +} from "@/views/activity-logs/activity-query"; +import { activityFiltersOf } from "@/views/activity-logs/activity-search"; + +import type { AdminScreenContext } from "../screen"; + +import { requireAdminPermission } from "../screen"; + +export const ADMIN_STAFF_LOGS_NAMESPACES = [ + "admin.staff", + "core.activity_logs", + "core.global", +] as const; + +export interface AdminStaffLogsRouteData { + description: string; + title: string; + type: PermissionStaffType; +} + +export const ACTIVITY_LOGS_MODULE = "activity_logs"; + +export const loadAdminStaffLogsRoute = async ({ + adminAccess, + queryClient, + search, + t, + type, +}: AdminScreenContext & { + search: ActivityRouteSearch; + t: PluginRouteTranslator; + type: PermissionStaffType; +}): Promise => { + requireAdminPermission(adminAccess, { + module: ACTIVITY_LOGS_MODULE, + permission: "can_view", + }); + + // The filter options decide which plugin messages the screen loads, so they + // are awaited; the first page warms alongside and the screen keeps its + // skeleton local if it is not there yet. + await Promise.all([ + queryClient.query({ + ...activityFilterOptionsQueryOptions(), + staleTime: "static", + }), + queryClient + .infiniteQuery({ + ...activityListQueryOptions({ + ...activityFiltersOf(search), + staff: type, + }), + staleTime: "static", + }) + .catch(() => undefined), + ]); + + return { + description: t(`admin.staff.logs.desc.${type}`), + title: t(`admin.staff.logs.title.${type}`), + type, + }; +}; diff --git a/packages/vitnode/src/tanstack/admin/staff/logs-screen.tsx b/packages/vitnode/src/tanstack/admin/staff/logs-screen.tsx new file mode 100644 index 0000000000..6cc93ae537 --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/staff/logs-screen.tsx @@ -0,0 +1,174 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; +import { DownloadIcon, Settings2Icon } from "lucide-react"; +import React from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import type { PermissionStaffType } from "@/api/lib/permission-staff"; +import type { ActivityRouteSearch } from "@/views/activity-logs/activity-search"; + +import { Button } from "@/components/ui/button"; +import { PageTitle } from "@/components/ui/page-title"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { Spinner } from "@/components/ui/spinner"; +import { manifest } from "@/manifest"; +import { activityPluginNamespaces } from "@/views/activity-logs/activity-labels"; +import { + activityFilterOptionsQueryOptions, + downloadActivityExport, +} from "@/views/activity-logs/activity-query"; +import { ActivityScreen } from "@/views/activity-logs/activity-screen"; +import { + activityFiltersOf, + activitySearchOf, +} from "@/views/activity-logs/activity-search"; + +import type { AdminTableNavigate } from "../table-search"; +import type { AdminStaffLogsRouteData } from "./logs-route"; + +import { RouteMessages } from "../../i18n/route-messages"; +import { useAdminPermission } from "../permissions"; +import { + ACTIVITY_LOGS_MODULE, + ADMIN_STAFF_LOGS_NAMESPACES, +} from "./logs-route"; + +const ActivityRetentionForm = React.lazy( + async () => await import("@/views/activity-logs/activity-retention-form"), +); + +const permission = (name: string) => ({ + module: ACTIVITY_LOGS_MODULE, + permission: name, + plugin: manifest.id, +}); + +const ExportButton = ({ + search, + type, +}: { + search: ActivityRouteSearch; + type: PermissionStaffType; +}) => { + const t = useTranslations("admin.staff.logs"); + const tError = useTranslations("core.global.errors"); + const [isExporting, setIsExporting] = React.useState(false); + + return ( + + ); +}; + +const RetentionSheet = ({ canManage }: { canManage: boolean }) => { + const t = useTranslations("admin.staff.logs.settings"); + const [isOpen, setIsOpen] = React.useState(false); + + return ( + + }> + + {t("open")} + + + + {t("title")} + + {t("desc")} + + + + + + } + > + { + setIsOpen(false); + }} + /> + + + + ); +}; + +export interface AdminStaffLogsRouteProps extends AdminStaffLogsRouteData { + navigate: AdminTableNavigate; + search: ActivityRouteSearch; +} + +/** AdminCP -> Staff -> Administrators / Moderators -> Logs. */ +export const AdminStaffLogsRouteContent = ({ + description, + navigate, + search, + title, + type, +}: AdminStaffLogsRouteProps) => { + const { data: options } = useSuspenseQuery( + activityFilterOptionsQueryOptions(), + ); + const canExport = useAdminPermission(permission("can_export")); + const canManageRetention = useAdminPermission( + permission("can_manage_retention"), + ); + + return ( + +

+ + ); +}; diff --git a/packages/vitnode/src/tanstack/admin/staff/screen.tsx b/packages/vitnode/src/tanstack/admin/staff/screen.tsx index cc0497675a..476803c672 100644 --- a/packages/vitnode/src/tanstack/admin/staff/screen.tsx +++ b/packages/vitnode/src/tanstack/admin/staff/screen.tsx @@ -1,7 +1,8 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; -import { PlusIcon } from "lucide-react"; +import { HistoryIcon, PlusIcon } from "lucide-react"; import React from "react"; +import { useTranslations } from "use-intl"; import type { DataTableNavigation } from "@/components/table/navigation"; @@ -9,8 +10,12 @@ import { AdminStaffPermissionGate } from "@/components/staff-permission/provider import { DataTableNavigationProvider } from "@/components/table/navigation"; import { buttonVariants } from "@/components/ui/button"; import { PageTitle } from "@/components/ui/page-title"; +import { manifest } from "@/manifest"; import { adminStaffPermissions } from "@/views/admin/views/core/shared/admin-permissions"; -import { staffCreateHref } from "@/views/admin/views/core/staff/staff-model"; +import { + staffCreateHref, + staffLogsHref, +} from "@/views/admin/views/core/staff/staff-model"; import { StaffTableContent } from "@/views/admin/views/core/staff/table/staff-table-content"; import type { AdminTableNavigate } from "../table-search"; @@ -18,6 +23,7 @@ import type { AdminStaffRouteData } from "./route"; import type { StaffRouteSearch, UncheckedStaffSearch } from "./route-search"; import { RouteMessages } from "../../i18n/route-messages"; +import { ACTIVITY_LOGS_MODULE } from "./logs-route"; import { adminStaffQuery, useStaffDeleteCallback } from "./query"; import { ADMIN_STAFF_NAMESPACES } from "./route"; import { staffSearchFrom, staffSearchParams } from "./route-search"; @@ -27,6 +33,26 @@ export interface AdminStaffRouteProps extends AdminStaffRouteData { search: UncheckedStaffSearch; } +const LogsLink = ({ type }: { type: AdminStaffRouteData["type"] }) => { + const t = useTranslations("admin.staff.logs"); + + return ( + + + + {t("open")} + + + ); +}; + export const AdminStaffRouteContent = ({ adminUserId, createLabel, @@ -59,6 +85,7 @@ export const AdminStaffRouteContent = ({
+ diff --git a/packages/vitnode/src/tests/audit-recorder.ts b/packages/vitnode/src/tests/audit-recorder.ts new file mode 100644 index 0000000000..f26f756a83 --- /dev/null +++ b/packages/vitnode/src/tests/audit-recorder.ts @@ -0,0 +1,31 @@ +import type { Table } from "drizzle-orm"; + +import { getTableName } from "drizzle-orm"; + +/** + * For statement-recording database mocks: answers inserts into + * `core_audit_logs` on its own, so the audit entry every audited write adds + * neither consumes the scripted results nor shows up among the recorded + * statements - and can be asserted on separately through `entries`. + */ +export const createAuditRecorder = () => { + const entries: Record[] = []; + + return { + entries, + /** The audit insert builder, or `null` for any other table. */ + insert: (table: unknown) => { + if (getTableName(table as Table) !== "core_audit_logs") return null; + + return { + values: (value: Record) => { + entries.push(value); + + return { + returning: async () => Promise.resolve([{ id: entries.length }]), + }; + }, + }; + }, + }; +}; diff --git a/packages/vitnode/src/tests/revisions-table.ts b/packages/vitnode/src/tests/revisions-table.ts index 6d18e08db2..336e0b40a6 100644 --- a/packages/vitnode/src/tests/revisions-table.ts +++ b/packages/vitnode/src/tests/revisions-table.ts @@ -4,6 +4,8 @@ import { PgDialect } from "drizzle-orm/pg-core"; import type { ContentTranslationRevisionSnapshot } from "@/content/revisions"; +import { createAuditRecorder } from "./audit-recorder"; + const dialect = new PgDialect(); const boundValue = (condition: SQL | undefined, column: string): unknown => { @@ -76,18 +78,21 @@ export const createTestRevisionsTable = () => { return query; }; + const audit = createAuditRecorder(); + const db = { - insert: () => ({ - values: (values: RevisionWrite) => ({ - returning: async () => { - nextId += 1; - written.push(values); - rows.push({ ...values, id: nextId }); - - return await Promise.resolve([{ id: nextId }]); - }, - }), - }), + insert: (table: unknown) => + audit.insert(table) ?? { + values: (values: RevisionWrite) => ({ + returning: async () => { + nextId += 1; + written.push(values); + rows.push({ ...values, id: nextId }); + + return await Promise.resolve([{ id: nextId }]); + }, + }), + }, select, transaction: async (body: (tx: unknown) => Promise) => await body(db), }; @@ -110,5 +115,5 @@ export const createTestRevisionsTable = () => { }); }; - return { db, seed, written }; + return { audits: audit.entries, db, seed, written }; }; diff --git a/packages/vitnode/src/views/activity-logs/activity-changes.tsx b/packages/vitnode/src/views/activity-logs/activity-changes.tsx new file mode 100644 index 0000000000..282a738fa8 --- /dev/null +++ b/packages/vitnode/src/views/activity-logs/activity-changes.tsx @@ -0,0 +1,111 @@ +import { LockIcon } from "lucide-react"; +import { useTranslations } from "use-intl"; + +import type { ActivityDetail } from "./activity-query"; + +/** A recorded JSON value as text: strings as they are, anything else as JSON. */ +export const formatActivityValue = (value: unknown): string => + typeof value === "string" ? value : JSON.stringify(value); + +/** The field names an entry's `metadata.changedFields` lists. */ +export const activityChangedFields = ( + metadata: Record, +): string[] => + Array.isArray(metadata.changedFields) + ? metadata.changedFields.filter( + (name): name is string => typeof name === "string", + ) + : []; + +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 ( + + {formatActivityValue(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..e275bee930 --- /dev/null +++ b/packages/vitnode/src/views/activity-logs/activity-detail.tsx @@ -0,0 +1,215 @@ +import { useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { ExternalLinkIcon, FilterIcon } from "lucide-react"; +import { useTranslations } from "use-intl"; + +import { DateFormat } from "@/components/date-format"; +import { Badge } from "@/components/ui/badge"; +import { Button, buttonVariants } from "@/components/ui/button"; + +import type { ActivityFilterOptions } from "./activity-query"; + +import { + activityChangedFields, + activityChangeRows, + ActivityChanges, + formatActivityValue, +} 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}
+
+); + +/** + * The body of the detail sheet: who, when, where and what changed, with the + * content revision the entry points at when there is one. + */ +export const ActivityDetailContent = ({ + id, + onFilterActor, + plugins, +}: { + id: number; + /** Narrows the list to this entry's actor; omitted where there is no list. */ + onFilterActor?: (actor: { id: number; name: string }) => void; + plugins: ActivityFilterOptions["plugins"]; +}) => { + const t = useTranslations("core.activity_logs.detail"); + const labels = useActivityLabels(plugins); + const { data: entry, isError } = useQuery(activityDetailQueryOptions(id)); + + if (isError) { + return ( +

+ {t("not_found")} +

+ ); + } + if (!entry) return ; + + const changes = activityChangeRows(entry.changes, name => + labels.field(entry, name), + ); + const revisionRows = (entry.revision?.fields ?? []).map(field => ({ + after: field.after, + before: field.before, + label: labels.field(entry, field.name), + name: field.name, + })); + const changedNames = activityChangedFields(entry.metadata); + const { changedFields: _changedFields, locale, ...metadata } = entry.metadata; + + 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]) => ( + + + {formatActivityValue(value)} + + + ))} +
+
+ ) : null} + + {entry.ipAddress || entry.correlationId ? ( +
+
+ {entry.ipAddress ? ( + + {entry.ipAddress} + + ) : null} + {entry.correlationId ? ( + + + {entry.correlationId} + + + ) : null} +
+
+ ) : null} +
+ ); +}; + +export default ActivityDetailContent; diff --git a/packages/vitnode/src/views/activity-logs/activity-filters.tsx b/packages/vitnode/src/views/activity-logs/activity-filters.tsx new file mode 100644 index 0000000000..6d01a91366 --- /dev/null +++ b/packages/vitnode/src/views/activity-logs/activity-filters.tsx @@ -0,0 +1,273 @@ +import { FilterXIcon, SearchIcon, XIcon } from "lucide-react"; +import React from "react"; +import { useTranslations } from "use-intl"; + +import type { AuditStaffLog } from "@/lib/audit/const"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + NativeSelect, + NativeSelectOption, +} from "@/components/ui/native-select"; +import { AUDIT_AREAS, AUDIT_CORE_PLUGIN_ID } from "@/lib/audit/const"; + +import type { ActivityFilterOptions, ActivityFilters } from "./activity-query"; + +import { useActivityLabels } from "./activity-labels"; + +/** + * Core's report and approval actions are only ever recorded in the moderator + * area, and core's other actions never are; plugin actions can be either. + */ +const isModerationAction = (action: string) => + action.startsWith("report.") || action.startsWith("submission."); + +/** The moderator area has a log of its own. */ +const ADMIN_LOG_AREAS = AUDIT_AREAS.filter(area => area !== "moderator"); + +const FilterSelect = ({ + id, + label, + onChange, + options, + value, +}: { + id: string; + label: string; + onChange: (value: string | undefined) => void; + options: { label: string; value: string }[]; + value: string | undefined; +}) => { + const t = useTranslations("core.activity_logs.filters"); + + return ( +
+ + { + onChange(event.target.value || undefined); + }} + size="sm" + value={value ?? ""} + > + {t("all")} + {options.map(option => ( + + {option.label} + + ))} + +
+ ); +}; + +/** + * The Activity Logs filters. Every change goes straight to `onChange`, which + * the screen writes into the URL - so a filtered view can be bookmarked and + * shared, and the back button undoes a filter. + */ +export const ActivityFiltersBar = ({ + actorName, + filters, + onChange, + options, + staff, +}: { + /** The name of the actor `filters.actorId` narrows to, for its chip. */ + actorName?: null | string; + filters: ActivityFilters; + onChange: (filters: ActivityFilters) => void; + options: ActivityFilterOptions; + /** The staff log being filtered - it decides which areas and actions apply. */ + staff: AuditStaffLog; +}) => { + const t = useTranslations("core.activity_logs.filters"); + const tAreas = useTranslations("core.activity_logs.areas"); + const labels = useActivityLabels(options.plugins); + // The draft belongs to the search it was typed against: once the URL's + // search changes (a reset, the back button), the field shows the URL again. + const [draft, setDraft] = React.useState({ + for: filters.search, + value: filters.search ?? "", + }); + const search = + draft.for === filters.search ? draft.value : (filters.search ?? ""); + const setSearch = (value: string) => { + setDraft({ for: filters.search, value }); + }; + + const set = + (key: TKey) => + (value: ActivityFilters[TKey] | undefined) => { + onChange({ ...filters, [key]: value }); + }; + + const actions = React.useMemo(() => { + const visible = options.actions.filter( + entry => + (!filters.pluginId || entry.pluginId === filters.pluginId) && + (staff === "moderator" + ? entry.pluginId !== AUDIT_CORE_PLUGIN_ID || + isModerationAction(entry.action) + : !isModerationAction(entry.action)), + ); + const unique = new Map(); + for (const entry of visible) { + if (!unique.has(entry.action)) { + unique.set( + entry.action, + labels.action({ + action: entry.action, + contentTypeId: null, + pluginId: entry.pluginId, + }), + ); + } + } + + return [...unique] + .map(([value, label]) => ({ label, value })) + .sort((a, b) => a.label.localeCompare(b.label)); + }, [filters.pluginId, labels, options.actions, staff]); + + const hasFilters = Object.values(filters).some( + value => value !== undefined && value !== "", + ); + + return ( +
+
{ + event.preventDefault(); + set("search")(search.trim() || undefined); + }} + > + +
+ { + setSearch(event.target.value); + }} + placeholder={t("search_placeholder")} + type="search" + value={search} + /> + +
+
+ +
+ { + onChange({ ...filters, action: undefined, pluginId: value }); + }} + options={options.plugins.map(plugin => ({ + label: labels.plugin(plugin.id), + value: plugin.id, + }))} + value={filters.pluginId} + /> + {staff === "admin" ? ( + { + set("area")(ADMIN_LOG_AREAS.find(area => area === value)); + }} + options={ADMIN_LOG_AREAS.map(area => ({ + label: tAreas(area), + value: area, + }))} + value={filters.area} + /> + ) : null} + +
+ + { + set("from")(event.target.value || undefined); + }} + type="date" + value={filters.from ?? ""} + /> +
+
+ + { + set("to")(event.target.value || undefined); + }} + type="date" + value={filters.to ?? ""} + /> +
+ {hasFilters ? ( + + ) : null} +
+ + {filters.actorId ? ( +
+ + {t("actor", { name: actorName ?? `#${filters.actorId}` })} + + +
+ ) : null} +
+ ); +}; diff --git a/packages/vitnode/src/views/activity-logs/activity-history.tsx b/packages/vitnode/src/views/activity-logs/activity-history.tsx new file mode 100644 index 0000000000..979df28586 --- /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 { + activityChangedFields, + 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 = activityChangedFields(entry.metadata); + 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..2d726dae48 --- /dev/null +++ b/packages/vitnode/src/views/activity-logs/activity-labels.tsx @@ -0,0 +1,207 @@ +import { Link } from "@tanstack/react-router"; +import { cn } from "cn"; +import React from "react"; +import { useTranslations } from "use-intl"; + +import type { ContentLabelTranslator } from "@/content/admin/labels"; +import type { AuditArea } from "@/lib/audit/const"; + +import { Badge } from "@/components/ui/badge"; +import { UserFormat } from "@/components/user-format"; +import { + contentI18nKeys, + contentNouns, + 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`]); + +/** `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 ContentLabelTranslator; + const titles = React.useMemo( + () => new Map(plugins.map(plugin => [plugin.id, plugin.title])), + [plugins], + ); + + return React.useMemo(() => { + const contentNoun = (pluginId: string, contentTypeId: null | string) => + // A filter option names the action, not a record of one type. + contentTypeId + ? contentNouns({ id: contentTypeId }, pluginId, tAll).singular + : t("content_noun"); + + const action = ( + entry: Pick, + ): string => { + const coreKey = `core.activity_logs.actions.${entry.action}`; + if (entry.action.startsWith("content.") && tAll.has(coreKey)) { + return tAll(coreKey, { + type: contentNoun(entry.pluginId, entry.contentTypeId), + }); + } + if (entry.pluginId === AUDIT_CORE_PLUGIN_ID && tAll.has(coreKey)) { + return tAll(coreKey); + } + const pluginKey = `${entry.pluginId}.audit.actions.${entry.action}`; + if (tAll.has(pluginKey)) return tAll(pluginKey); + + return humanizeAuditAction(entry.action); + }; + + const field = ( + entry: Pick, + name: string, + ): string => { + if (entry.contentTypeId) { + const key = contentI18nKeys( + { id: entry.contentTypeId }, + entry.pluginId, + ).field(name); + if (tAll.has(key)) return tAll(key); + } + const coreKey = `core.activity_logs.fields.${name}`; + if (tAll.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..e7c150b466 --- /dev/null +++ b/packages/vitnode/src/views/activity-logs/activity-query.ts @@ -0,0 +1,241 @@ +import { + infiniteQueryOptions, + keepPreviousData, + queryOptions, +} from "@tanstack/react-query"; + +import type { AuditArea, AuditStaffLog } from "@/lib/audit/const"; + +import { fetcherClient } from "@/lib/fetcher-client"; +import { OPERATIONAL_STALE_TIME } from "@/lib/query-freshness"; +import { manifest } from "@/manifest"; +import { fetcher } from "@/tanstack/fetcher"; + +/** The filters an Activity Logs list is read with, as they sit in the URL. */ +export interface ActivityFilters { + action?: string; + actorId?: number; + area?: AuditArea; + from?: string; + pluginId?: string; + search?: string; + targetId?: string; + targetType?: string; + to?: string; +} + +export class ActivityRequestError extends Error { + constructor(status: number, what: string) { + super(`Reading ${what} answered ${status}.`); + this.name = "ActivityRequestError"; + this.status = status; + } + + readonly status: number; +} + +/** Every cached Activity Logs read - invalidate it after a write. */ +export const ACTIVITY_QUERY_ROOT = ["vitnode", "activity-logs"] as const; + +const nextCursor = (last: { + pageInfo: { endCursor: null | string; hasNextPage: boolean }; +}) => + last.pageInfo.hasNextPage + ? (last.pageInfo.endCursor ?? undefined) + : undefined; + +/** The filters of one staff log, as the API reads them. */ +export type ActivityStaffFilters = ActivityFilters & { staff: AuditStaffLog }; + +const fetchPage = async (filters: ActivityStaffFilters, cursor?: string) => { + const response = await fetcher({ + plugin: manifest.id, + args: { query: { ...filters, ...(cursor ? { cursor } : {}) } }, + method: "get", + module: "admin/activity-logs", + path: "/", + }); + if (!response.ok) { + throw new ActivityRequestError(response.status, "the activity log"); + } + + return await response.json(); +}; + +export type ActivityPage = Awaited>; + +export type ActivityEntry = ActivityPage["edges"][number]; + +/** + * The list, page by page: a keyset cursor newest-first, so "Load more" is as + * cheap on the hundredth page as on the first. Keyed on the filters, and keeps + * the previous result on screen while a new filter loads. + */ +export const activityListQueryOptions = (filters: ActivityStaffFilters) => + infiniteQueryOptions({ + getNextPageParam: nextCursor, + initialPageParam: undefined as string | undefined, + placeholderData: keepPreviousData, + queryFn: async ({ pageParam }) => await fetchPage(filters, pageParam), + queryKey: [...ACTIVITY_QUERY_ROOT, "list", filters], + retry: false, + staleTime: OPERATIONAL_STALE_TIME, + }); + +const fetchDetail = async (id: number) => { + const response = await fetcher({ + plugin: manifest.id, + args: { params: { id } }, + method: "get", + module: "admin/activity-logs", + path: "/{id}", + }); + if (!response.ok) { + throw new ActivityRequestError(response.status, "an activity entry"); + } + + return await response.json(); +}; + +export type ActivityDetail = Awaited>; + +export const activityDetailQueryOptions = (id: number) => + queryOptions({ + queryFn: async () => await fetchDetail(id), + queryKey: [...ACTIVITY_QUERY_ROOT, "detail", id], + retry: false, + staleTime: OPERATIONAL_STALE_TIME, + }); + +const fetchFilterOptions = async () => { + const response = await fetcher({ + plugin: manifest.id, + method: "get", + module: "admin/activity-logs", + path: "/filters", + }); + if (!response.ok) { + throw new ActivityRequestError(response.status, "activity filters"); + } + + return await response.json(); +}; + +export type ActivityFilterOptions = Awaited< + ReturnType +>; + +/** The plugins and actions the filters offer. Changes only with a deploy. */ +export const activityFilterOptionsQueryOptions = () => + queryOptions({ + queryFn: fetchFilterOptions, + queryKey: [...ACTIVITY_QUERY_ROOT, "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: nextCursor, + initialPageParam: undefined as string | undefined, + queryFn: async ({ pageParam }) => await fetchEntityPage(target, pageParam), + queryKey: [...ACTIVITY_QUERY_ROOT, "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: [...ACTIVITY_QUERY_ROOT, "settings"], + retry: false, + staleTime: OPERATIONAL_STALE_TIME, + }); + +/** Saves the retention period; the caller invalidates and toasts. */ +export const saveActivitySettings = async (retentionDays: number) => { + const response = await fetcher({ + plugin: manifest.id, + args: { body: { retentionDays } }, + method: "put", + module: "admin/activity-logs", + path: "/settings", + }); + if (!response.ok) { + throw new ActivityRequestError(response.status, "activity settings"); + } + + return await response.json(); +}; + +/** + * Downloads the CSV export of the current filters - the existing download + * pattern: fetched with the AdminCP session, saved through an object URL. The + * API checks `can_export` and leaves IP addresses out without + * `can_view_sensitive`. Browser-only. + */ +export const downloadActivityExport = async ( + filters: ActivityStaffFilters, +): Promise => { + const response = await fetcherClient({ + plugin: manifest.id, + args: { query: filters }, + method: "get", + module: "admin/activity-logs", + options: { credentials: "include" }, + path: "/export", + }); + if (!response.ok) { + throw new ActivityRequestError(response.status, "the activity export"); + } + + const objectUrl = URL.createObjectURL(await response.blob()); + const anchor = document.createElement("a"); + anchor.href = objectUrl; + anchor.download = `${filters.staff}-logs-${new Date().toISOString().slice(0, 10)}.csv`; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(objectUrl); +}; diff --git a/packages/vitnode/src/views/activity-logs/activity-retention-form.tsx b/packages/vitnode/src/views/activity-logs/activity-retention-form.tsx new file mode 100644 index 0000000000..6d9c618c3a --- /dev/null +++ b/packages/vitnode/src/views/activity-logs/activity-retention-form.tsx @@ -0,0 +1,105 @@ +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 { + ACTIVITY_QUERY_ROOT, + activitySettingsQueryOptions, + saveActivitySettings, +} from "./activity-query"; + +const OPTIONS = AUDIT_RETENTION_DAYS_OPTIONS.map(String) as [ + string, + ...string[], +]; + +/** + * The retention form, in the settings sheet. Saving refreshes the cached + * settings, toasts what will happen next and closes the sheet. + */ +export const ActivityRetentionForm = ({ + canManage, + onSaved, +}: { + canManage: boolean; + onSaved: () => void; +}) => { + const t = useTranslations("admin.staff.logs.settings"); + const tGlobal = useTranslations("core.global"); + const tError = useTranslations("core.global.errors"); + const queryClient = useQueryClient(); + const { data } = useSuspenseQuery(activitySettingsQueryOptions()); + + const period = (days: number) => + days === 0 ? t("forever") : t("days", { count: days }); + + const formSchema = z.object({ + retentionDays: z.enum(OPTIONS).default(String(data.retentionDays)), + }); + + const onSubmit: AutoFormOnSubmit = async values => { + const retentionDays = Number(values.retentionDays); + try { + await saveActivitySettings(retentionDays); + } catch { + toast.error(tError("title"), { description: t("error") }); + + return; + } + + await queryClient.invalidateQueries({ queryKey: ACTIVITY_QUERY_ROOT }); + toast.success(t("saved"), { + description: + retentionDays === 0 + ? t("saved_forever") + : t("saved_desc", { period: period(retentionDays) }), + }); + onSaved(); + }; + + return ( + ( + ({ + label: period(Number(value)), + value, + }))} + /> + ), + id: "retentionDays", + }, + ]} + formSchema={formSchema} + layout={rendered => ( + <> +
+ {rendered.retentionDays} +
+ + + )} + onSubmit={onSubmit} + /> + ); +}; + +export default ActivityRetentionForm; diff --git a/packages/vitnode/src/views/activity-logs/activity-screen.tsx b/packages/vitnode/src/views/activity-logs/activity-screen.tsx new file mode 100644 index 0000000000..8d54f640c1 --- /dev/null +++ b/packages/vitnode/src/views/activity-logs/activity-screen.tsx @@ -0,0 +1,152 @@ +import { useInfiniteQuery, useSuspenseQuery } from "@tanstack/react-query"; +import { CircleAlertIcon } from "lucide-react"; +import React from "react"; +import { useTranslations } from "use-intl"; + +import type { AuditStaffLog } from "@/lib/audit/const"; + +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; +import { Skeleton } from "@/components/ui/skeleton"; + +import type { ActivityEntry, ActivityFilters } from "./activity-query"; + +import { ActivityFiltersBar } from "./activity-filters"; +import { + activityFilterOptionsQueryOptions, + activityListQueryOptions, +} from "./activity-query"; +import { ActivitySheet } from "./activity-sheet"; +import { ActivityTable } from "./activity-table"; + +/** Rows shaped like the table's, for the first load only - a refetch keeps the data. */ +export const ActivityListPending = () => { + const t = useTranslations("core.activity_logs"); + + return ( +
+ + {t("loading")} + + {Array.from({ length: 8 }, (_, index) => ( + + ))} +
+ ); +}; + +export interface ActivityScreenProps { + /** Toolbar actions beside the filters - export and retention. */ + actions?: React.ReactNode; + /** The entry whose sheet is open, from the URL. */ + entryId: null | number; + filters: ActivityFilters; + /** Writes filters and the open entry back into the URL. */ + onNavigate: (next: { + entryId: null | number; + filters: ActivityFilters; + }) => void; + /** Which staff log: admins' (with system and member activity) or moderators'. */ + staff: AuditStaffLog; +} + +/** + * Filters, the list and the detail sheet of one staff log - the admins' or the + * moderators' Activity Logs in AdminCP -> Staff. + */ +export const ActivityScreen = ({ + actions, + entryId, + filters, + onNavigate, + staff, +}: ActivityScreenProps) => { + const t = useTranslations("core.activity_logs"); + const { data: options } = useSuspenseQuery( + activityFilterOptionsQueryOptions(), + ); + const list = useInfiniteQuery( + activityListQueryOptions({ ...filters, staff }), + ); + const entries = React.useMemo( + () => list.data?.pages.flatMap(page => page.edges) ?? [], + [list.data], + ); + const [openedFrom, setOpenedFrom] = React.useState(); + const [actorName, setActorName] = React.useState(null); + + const actorFromList = filters.actorId + ? entries.find(entry => entry.actor?.id === filters.actorId)?.actor?.name + : undefined; + + return ( +
+
+
+ { + onNavigate({ entryId: null, filters: next }); + }} + options={options} + staff={staff} + /> +
+ {actions ? ( +
{actions}
+ ) : null} +
+ + {list.isPending ? : null} + {list.isError && !list.data ? ( + + + + + + {t("error_title")} + {t("error_desc")} + + + ) : null} + {list.data ? ( + { + void list.fetchNextPage(); + }} + hasNextPage={list.hasNextPage} + isFetching={list.isFetching} + isFetchingNextPage={list.isFetchingNextPage} + onOpen={entry => { + setOpenedFrom(entry); + onNavigate({ entryId: entry.id, filters }); + }} + plugins={options.plugins} + /> + ) : null} + + { + setActorName(actor.name); + onNavigate({ + entryId: null, + filters: { ...filters, actorId: actor.id }, + }); + }} + onOpenChange={open => { + if (!open) onNavigate({ entryId: null, filters }); + }} + plugins={options.plugins} + /> +
+ ); +}; diff --git a/packages/vitnode/src/views/activity-logs/activity-search.test.ts b/packages/vitnode/src/views/activity-logs/activity-search.test.ts new file mode 100644 index 0000000000..e93661826e --- /dev/null +++ b/packages/vitnode/src/views/activity-logs/activity-search.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeActivitySearch } from "./activity-search"; + +describe("normalizeActivitySearch", () => { + it("keeps every recognised filter", () => { + const search = { + action: "role.updated", + actorId: 7, + area: "admin", + from: "2026-01-01", + log: "12", + pluginId: "@vitnode/blog", + search: "News", + targetId: "42", + targetType: "blog.category", + to: "2026-01-31", + }; + + expect(normalizeActivitySearch(search)).toEqual({ + ...search, + log: 12, + }); + }); + + it.each([ + ["action", "Role Updated"], + ["actorId", "-1"], + ["actorId", "1.5"], + ["area", "superadmin"], + ["from", "01/01/2026"], + ["log", "0"], + ["pluginId", "

r{8tBESr1 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 + +To show the same history on your own AdminCP screen, render `ActivityHistory` +inside `RouteMessages` with the Activity Logs messages and your plugin's +content labels: + +```tsx title="plugins/blog/src/views/admin/category-activity.tsx" +import { RouteMessages } from '@vitnode/core/tanstack/i18n' +import { ActivityHistory } from '@vitnode/core/views/activity-logs/activity-history' + +export const CategoryActivity = ({ categoryId }: { categoryId: number }) => ( + + + +) +``` + +`ActivityHistory` reads `GET /admin/activity-logs/entity`, so it shows entries +only to admins allowed to read that record's history. `RouteMessages` suspends +while the messages load, so render it inside a `Suspense` boundary with a +skeleton fallback. + +## Related + +- [Activity Logs](/docs/dev/audit-logs): where staff read every entry +- [Record plugin events](/docs/dev/audit-logs/record-plugin-events): audit your plugin's own tables +- [Revisions](/docs/content-engine/editorial): what a record used to be, and the History panel diff --git a/apps/web/content/docs/content-engine/meta.json b/apps/web/content/docs/content-engine/meta.json index 228837fc92..13ec5f0959 100644 --- a/apps/web/content/docs/content-engine/meta.json +++ b/apps/web/content/docs/content-engine/meta.json @@ -47,6 +47,7 @@ "editorial-scheduling", "live-editing", "duplicate-content", + "activity-logs", "---Customization---", "admincp", "services", diff --git a/apps/web/content/docs/dev/audit-logs/access-and-retention.mdx b/apps/web/content/docs/dev/audit-logs/access-and-retention.mdx new file mode 100644 index 0000000000..b233efcfc0 --- /dev/null +++ b/apps/web/content/docs/dev/audit-logs/access-and-retention.mdx @@ -0,0 +1,76 @@ +--- +title: Access and retention +description: Who may read the VitNode admin and moderator logs in AdminCP Staff, how long entries are kept, and how to export them as CSV. +icon: ShieldCheck +--- + +The Activity Logs know a lot about who did what, so reading them takes an +admin permission of its own, and entries expire after a retention period you +pick. The API enforces every rule on this page. The UI only hides what you could +not open anyway. + +## Permissions + +Grant them in **AdminCP → Staff → Administrators**, in the **Activity Logs** +module, like any other [staff permission](/docs/dev/working-with-users/staff-permissions). +Every permission except `can_view` depends on `can_view`. + +| Permission (module `activity_logs`) | Label in the editor | Allows | +| ----------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `can_view` | View activity logs | The **Logs** buttons, both staff logs, entry details, the retention period (read-only), any record's history | +| `can_view_sensitive` | View IP addresses and technical details | IP addresses and request ids in entry details, and the IP address column in exports | +| `can_export` | Export activity logs | **Export CSV** with the current filters | +| `can_manage_retention` | Change how long activity logs are kept | Saving a new retention period | + +An unrestricted administrator holds them all. An admin without `can_view` can +still open the **Activity** panel of a content record when they hold +`can_view` on that content type. + +## Two logs, one table + +Both staff logs read the same entries, split by the area they were recorded in: + +| Page | Shows | +| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| **Admin logs** (Staff → Administrators → Logs) | The `admin`, `public` and `system` areas: AdminCP changes, member writes, scheduled jobs, cron and queue work | +| **Moderator logs** (Staff → Moderators → Logs) | The `moderator` area: report workflow steps, approved and rejected submissions, content acted on from a report | + +Only the admin log has an **Area** filter. Both logs show the **Area** column +only when the entries on screen come from more than one area, so the moderator +log never shows it. Moderators do not get a log in the ModeratorCP. What staff +did is for the admins who manage them to read. + +## Retention + +Open **Logs → Retention settings** on either staff log and pick how long +entries are kept: 30, 90, 180, 365 (the default) or 730 days, or **Forever**. +One period applies to both logs, and only `can_manage_retention` can save it. + +The `audit-retention` cron job runs once a day (`40 3 * * *`) and deletes +older entries oldest first, in batches of 5,000 and at most 100 batches per +run. A bigger backlog waits for the next day. When it removes anything, it +records one **Deleted expired log entries** entry. No route deletes or edits an +entry, so the log is append-only. + +## Export + +**Export CSV** downloads up to 10,000 entries matching the current filters of +the log you are on, newest first, as `activity-logs-YYYY-MM-DD.csv`. Values a +spreadsheet would run as a formula (starting with `=`, `+`, `-` or `@`) are +prefixed with `'`. The IP address column needs `can_view_sensitive`. Every +export is logged too, as **Exported the activity log**, because sooner or later +someone asks who downloaded the log. + +## 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. When an admin edits +a user, email addresses, birthdays and other personal fields show up as changed, +without their values. Passwords and secrets never have their values stored +either. IP addresses are stored for staff and member actions only, never for +system entries, and expire with the rest of the entry. + +## Related + +- [Activity Logs](/docs/dev/audit-logs): what is recorded and where to read it +- [Activity Logs reference](/docs/dev/audit-logs/reference): routes, filters and limits diff --git a/apps/web/content/docs/dev/audit-logs/activity-logs-admin-list.webp b/apps/web/content/docs/dev/audit-logs/activity-logs-admin-list.webp new file mode 100644 index 0000000000000000000000000000000000000000..47c1fd4cabf136815e073149e908cd411b95cff3 GIT binary patch literal 126696 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 umLWY)9A1Nf4fs%B0U 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 D%{imH literal 0 HcmV?d00001 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..bb84690923 --- /dev/null +++ b/apps/web/content/docs/dev/audit-logs/index.mdx @@ -0,0 +1,83 @@ +--- +title: Activity Logs +description: VitNode's audit log answers who changed what, when and on which resource, across the AdminCP, moderators, plugins and Content Engine content. +icon: History +--- + +import { ImgDocs } from '@/components/fumadocs/img' + +import adminList from './activity-logs-admin-list.webp' +import detailSheet from './activity-logs-detail-sheet.webp' + +Activity Logs (audit logs) are where VitNode answers "wait, who changed +this?". They record administrative, moderation, plugin and content operations +in one place. Every entry says who acted, from +which area (AdminCP, ModeratorCP, the site or the system), which plugin and +action it was, which resource it touched and what changed. You read them in the +AdminCP, next to your staff: + +- **Admin logs**: **AdminCP → Staff → Administrators → Logs** + (`/admin/core/staff/admins/logs`). AdminCP changes, plus what members, + plugins and scheduled jobs did. +- **Moderator logs**: **AdminCP → Staff → Moderators → Logs** + (`/admin/core/staff/moderators/logs`). What moderators did: reports, + approvals and hidden content. + +Each staff list has a **Logs** button that opens its log. + + + +Filter by plugin, action, date range or free text (target, action and actor +name). The admin log also filters by area, and shows an **Area** column only +when the entries on screen come from more than one area. Click an entry to open +its details: the actor, the plugin, the target, each changed field before and +after, an **Open** link to the target and **Show this person's activity**. + + + +## What gets 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 and log exports | +| Moderation | Taking, assigning, resolving and reopening reports, report notes, acting on reported content, approving and rejecting submissions | +| Background work | Scheduled publishing, cron jobs and queue tasks, attributed to the system; manual cron runs, attributed to the admin who started them | +| Payments | Checkouts started by members, and every status change the provider reports (processing, succeeded, failed, expired) | + +Your plugin's own operations, such as creating a note or refunding an order, +take one call. See [Record plugin events](/docs/dev/audit-logs/record-plugin-events). + +## Audit logs, revisions and technical logs + +VitNode keeps three kinds of history, and each one answers a different +question. + +| 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` | + +For an editorial content type, the audit entry points at the revision it +created instead of copying the content. The detail sheet then shows what changed +since the previous revision. + +## Choose a guide + +| You want to | Read | +| ----------------------------------------------------------------- | ------------------------------------------------------------------ | +| Record your plugin's own operations, with before/after changes | [Record plugin events](/docs/dev/audit-logs/record-plugin-events) | +| Understand or tune what the Content Engine records | [Content Engine activity logs](/docs/content-engine/activity-logs) | +| Decide who may read the logs, how long they are kept, export them | [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..f87c0cba50 --- /dev/null +++ b/apps/web/content/docs/dev/audit-logs/meta.json @@ -0,0 +1,11 @@ +{ + "title": "Activity Logs", + "description": "Who changed what and when: one audit trail for admins, moderators, plugins and content", + "icon": "History", + "pages": [ + "index", + "record-plugin-events", + "access-and-retention", + "reference" + ] +} diff --git a/apps/web/content/docs/dev/audit-logs/record-plugin-events.mdx b/apps/web/content/docs/dev/audit-logs/record-plugin-events.mdx new file mode 100644 index 0000000000..34bdac0b08 --- /dev/null +++ b/apps/web/content/docs/dev/audit-logs/record-plugin-events.mdx @@ -0,0 +1,172 @@ +--- +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 +--- + +The Content Engine audits its own writes. Your plugin's own tables are your +job, but only barely: call `c.get('audit').record()` from the API route that +makes the change. VitNode takes the actor, IP address and request id from the +request. You describe the operation. + +In this guide, the `site-notes` plugin's **create note** route writes to the +admin log. + +## Before you begin + +Finish [Permissions](/docs/dev/working-with-users/staff-permissions). It adds +the `site_notes` table, the AdminCP route at `admin/notes` and a temporary +**Create a test note** button. This guide changes that route. + + + + +### Record the operation in the same transaction + +Replace the handler of `createNoteRoute`. Write the entry in the same +transaction as the change. A rolled-back insert then leaves no entry behind, and +a failed audit write fails the insert instead of quietly losing the trail. + +```ts title="plugins/site-notes/src/api/modules/admin/notes/create.route.ts" +export const createNoteRoute = buildRoute({ + adminStaffPermission: { module: 'notes', permission: 'can_create' }, + route: { + // unchanged + }, + handler: async (c) => { + const values = c.req.valid('json') + const note = await c.get('db').transaction(async (tx) => { + const [created] = await tx.insert(site_notes).values(values).returning({ + id: site_notes.id, + title: site_notes.title, + body: site_notes.body, + }) + + await c.get('audit').record( + { + action: 'note.created', + target: { type: 'note', id: created.id, label: created.title }, + changes: c.get('audit').diff({ + before: null, + after: created, + include: ['title', 'body'], + }), + }, + { tx }, + ) + + return created + }) + + return c.json(note, 201) + }, +}) +``` + +`pluginId` defaults to the plugin of the route, so the entry is filed under +`site-notes`. `area` defaults to `admin` on an AdminCP request, `public` for a +signed-in member and `system` otherwise. Pass `area: 'moderator'` from +moderator routes so the entry lands in the moderator log, and `scope` when the +action belongs to a moderation scope. + + + + +### Describe the changes + +`diff()` (also exported as `auditDiff` from `@vitnode/core/lib/audit/diff`) +compares two objects and keeps only the fields that changed. `before: null` +describes a creation, `after: null` a deletion: + +```ts +c.get('audit').diff({ + before: { title: 'News', slug: 'news', viewCount: 10 }, + after: { title: 'Announcements', slug: 'announcements', viewCount: 11 }, + exclude: ['viewCount'], +}) +// { title: { from: 'News', to: 'Announcements' }, +// slug: { from: 'news', to: 'announcements' } } +``` + +Prefer `include` for anything security-related. An allowlist cannot leak a +column added later. Fields named like passwords, tokens, secrets, API keys, +cookies or recovery codes are always stored as `{ redacted: true }`. The entry +says they changed, never what to. Rich-text documents are stored as a marker, +because 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 before they +first happen. Create `src/api/audit-actions.ts`: + +```ts title="plugins/site-notes/src/api/audit-actions.ts" +import { defineAuditActions } from '@vitnode/core/lib/audit/actions' + +export const siteNotesAuditActions = defineAuditActions([ + { action: 'note.created', targetType: 'note' }, +]) +``` + +Pass it to `buildApiPlugin` in `src/config.api.ts`. The snippet is an excerpt, +so keep the rest of the file: + +```ts title="plugins/site-notes/src/config.api.ts" +import { siteNotesAuditActions } from './api/audit-actions' // [!code ++] + +export const siteNotesApiPlugin = () => + buildApiPlugin({ + manifest, + auditActions: siteNotesAuditActions, // [!code ++] + // ... + }) +``` + +Add the label to `src/locales/en.json`, inside the `site-notes` object: + +```json title="plugins/site-notes/src/locales/en.json" +{ + "site-notes": { + "audit": { + "actions": { + "note": { "created": "Created note" } + } + } + } +} +``` + +The label key is `{pluginId}.audit.actions.{action}`, with the dots of the +action nesting. Without a label, the UI makes the action id readable +("Note created"). An undeclared action is still recorded and listed. It just +won't show up in the action filter. + + + + +## Check the result + +Press **Create a test note** as the unrestricted administrator, then open +**AdminCP → Staff → Administrators → Logs**. The newest entry reads +**Created note** under your plugin, with the note's title as the target. Click +it, and **Changes** shows **Title** and **Body** going from **Empty** to the new +values. + +## Background work and failures + +- In a cron job or queue task, entries are attributed to the **system** + automatically. To add context to your own block of work, wrap it in + `withAuditContext` from `@vitnode/core/api/lib/audit/context`, for example + with `{ metadata: { trigger: 'import' } }`. +- A malformed action or target type throws `AuditLogError`. That is a bug in + your code, so VitNode never swallows it. For a non-critical operation outside + a transaction, pass + `{ bestEffort: true }` to log a failed database write to the technical log + instead of failing the request. + +## Related + +- [Activity Logs reference](/docs/dev/audit-logs/reference): every option and limit +- [Access and retention](/docs/dev/audit-logs/access-and-retention): who can read the entries 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..a9e3dfeb17 --- /dev/null +++ b/apps/web/content/docs/dev/audit-logs/reference.mdx @@ -0,0 +1,159 @@ +--- +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 +--- + +This page lists every server-side Activity Logs API in `@vitnode/core`, plus +the stored fields, the admin routes and their limits. + +## `c.get('audit').record(input, options?)` + +Writes one entry. Server-only. Returns the entry id, or `null` when a +`withAuditContext` frame dropped it (an enclosing operation records it as a +whole) or a best-effort write failed. + +| Name | Type | Required | Default | Description | +| ---------- | ------------------------------------------------ | -------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `action` | `string` | Yes | | Lowercase letters, digits and `_`, joined by dots, at most 150 characters: `note.created` | +| `target` | `{ type, id?, label? }` | Yes | | `type` like `note` or `blog.category` (at most 100 characters); `id` a number or string, stored as text (cut at 64); `label` kept after deletion (cut at 255) | +| `area` | `'admin' \| 'moderator' \| 'public' \| 'system'` | No | See below | Which staff log the entry belongs to: `moderator` goes to the moderator log, every other area to the admin log | +| `changes` | `Record` | No | `null` | Usually from `diff()`; sanitized and capped at 50 fields and 16 KB | +| `metadata` | `Record` | No | `{}` | Small, non-personal facts; sanitized and capped at 16 KB | +| `pluginId` | `string` | No | The route's plugin | Which plugin the entry belongs to | +| `scope` | `{ family, id }` | No | `null` | The moderation scope the action was about, such as `{ family: 'forum', id: 4 }` | + +Without `area`, a `withAuditContext` frame's area applies, then `admin` on an +AdminCP request, `public` for a signed-in member and `system` otherwise. + +| Option | Type | Default | Description | +| ------------ | ------------------- | ------- | ----------------------------------------------------------------------- | +| `tx` | Drizzle transaction | | Write with the change's transaction: both 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 (or a trusted `withAuditContext` frame), never from `input`. A +member writing in the `moderator` area is recorded as staff. Malformed input +throws `AuditLogError` from `@vitnode/core/api/lib/audit/write`, even with +`bestEffort`. + +Values are sanitized before they are stored: strings are cut at 500 +characters, arrays and objects at 50 items, nesting at 4 levels, errors keep +only their name, and sensitive keys are redacted. + +## `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`. + +- `before: null` describes a creation, `after: null` a deletion. 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 }`: passwords, hashes, tokens, + secrets, credentials, cookies, API, access and private keys, recovery codes. +- `include` compares only the listed fields; `exclude` skips fields. + +## `withAuditContext(c, frame, body)` + +From `@vitnode/core/api/lib/audit/context`. Server-only. Applies trusted +context to every entry `body` writes on `c` and returns what `body` returns. +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; the entry's own keys win | +| `scope` | Moderation scope for entries that do not name one | +| `intercept` | Rewrite each entry before it is written, or return `null` to drop it | + +Cron jobs and queue tasks already run inside a system frame with +`{ actor: AUDIT_SYSTEM_ACTOR, area: 'system' }` and a `trigger` in the metadata. + +```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 importNotes(c), +) +``` + +## `defineAuditActions(actions)` + +From `@vitnode/core/lib/audit/actions`. Universal. Validates +`{ action, targetType }[]` for `buildApiPlugin({ auditActions })` and throws +when an action or target type is not a valid identifier, or an action is +declared twice. Declaring is optional. It only adds the actions to the action +filter. 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` (a member) or `system`; `actorId` becomes `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 + +The admin routes live in core's `admin/activity-logs` module. Every one needs +an AdminCP session. + +| Method and path | Permission | Returns | +| --------------- | --------------------------------------------- | ------------------------------------------------------------------------- | +| `GET /` | `activity_logs` `can_view` | Keyset page, newest first | +| `GET /{id}` | `can_view` | Entry with changes, revision diff and an AdminCP link; `404` when missing | +| `GET /entity` | `can_view`, or `can_view` on the content type | One target's history with changes; `403` otherwise | +| `GET /filters` | `can_view` | Declared actions and installed plugins | +| `GET /export` | `can_export` | CSV, at most 10,000 rows | +| `GET /settings` | `can_view` | `{ retentionDays }` | +| `PUT /settings` | `can_manage_retention` | `{ retentionDays }`: `0` (forever), 30, 90, 180, 365 or 730 | + +List filters: `staff` (`moderator` keeps the moderator area, `admin` every +other area), `pluginId`, `area`, `actorId`, `action`, `targetType`, `targetId`, +`from` and `to` (`YYYY-MM-DD`, UTC, inclusive), `search` (target label, action +id and actor name), `cursor` and `first` (1-100, default 25). The export takes +the same filters without `cursor` and `first`. `/entity` takes `pluginId`, +`targetType` and `targetId` (all required), plus `cursor` and `first`. + +Read one record's history from an AdminCP screen with `fetcher`: + +```ts +import { fetcher } from '@vitnode/core/tanstack/fetcher' + +const response = await fetcher({ + plugin: '@vitnode/core', + method: 'get', + module: 'admin/activity-logs', + path: '/entity', + args: { + query: { pluginId: 'site-notes', targetType: 'note', targetId: '1' }, + }, +}) +if (!response.ok) + throw new Error(`Activity history answered ${response.status}`) +const { edges, pageInfo } = await response.json() +``` + +Pass `pageInfo.endCursor` as `cursor` to read older entries while +`pageInfo.hasNextPage` is `true`. To show it in the AdminCP without writing the +UI, render [`ActivityHistory`](/docs/content-engine/activity-logs#see-a-records-history-in-the-admincp). diff --git a/apps/web/content/docs/dev/meta.json b/apps/web/content/docs/dev/meta.json index f7d272072a..f037023aa4 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/content/docs/dev/payments/index.mdx b/apps/web/content/docs/dev/payments/index.mdx index bca29e65e2..41cc6e26ab 100644 --- a/apps/web/content/docs/dev/payments/index.mdx +++ b/apps/web/content/docs/dev/payments/index.mdx @@ -37,13 +37,13 @@ status page (at most every 15 seconds per payment), and from the ## Statuses -| Status | Meaning | Can change to | -| ------------ | ---------------------------------------------------------------------------------- | ------------------------------------------- | -| `pending` | Stored; the checkout is being created or is open | `processing`, `succeeded`, `failed`, `expired` | -| `processing` | The customer finished, but a delayed payment method has not settled | `succeeded`, `failed` | -| `succeeded` | The provider confirmed the money | nothing | -| `failed` | The provider reported a final failure, or refused to create the checkout | nothing | -| `expired` | The checkout closed without payment, or was never created and nobody got its URL | nothing | +| Status | Meaning | Can change to | +| ------------ | -------------------------------------------------------------------------------- | ---------------------------------------------- | +| `pending` | Stored; the checkout is being created or is open | `processing`, `succeeded`, `failed`, `expired` | +| `processing` | The customer finished, but a delayed payment method has not settled | `succeeded`, `failed` | +| `succeeded` | The provider confirmed the money | nothing | +| `failed` | The provider reported a final failure, or refused to create the checkout | nothing | +| `expired` | The checkout closed without payment, or was never created and nobody got its URL | nothing | Final statuses never change, so a late, duplicated or out-of-order webhook cannot undo a success. A declined card is not `failed`: the customer stays on @@ -83,6 +83,16 @@ default events adapter runs listeners inside the request that changed the status (a webhook, a status refresh or the cron job) and never retries them, so do not grant anything from a listener alone: read the stored payment instead. +## Activity Logs + +Every attempt and every status change lands in the +[Activity Logs](/docs/dev/audit-logs) under **Admin logs**, written in the same +transaction as the change. Starting a checkout is credited to the customer; +a status move comes from the provider, so it is credited to the system. Each +entry keeps the amount (in minor units), currency, provider, reference and the +plugin that charged. Card details never reach VitNode, so they never reach the +log either. + ## Guides diff --git a/apps/web/src/page-cache.gen.ts b/apps/web/src/page-cache.gen.ts index 4b2d78bd0d..8e11435303 100644 --- a/apps/web/src/page-cache.gen.ts +++ b/apps/web/src/page-cache.gen.ts @@ -22,7 +22,9 @@ export const pageCacheRoutes: PageCacheRouteTable = [ { path: '/admin/core/staff/admins/edit/:id' }, { path: '/admin/core/staff/moderators/edit/:id' }, { path: '/admin/core/staff/admins/create' }, + { path: '/admin/core/staff/admins/logs' }, { path: '/admin/core/staff/moderators/create' }, + { path: '/admin/core/staff/moderators/logs' }, { path: '/admin/core/ai/actions' }, { path: '/admin/core/debug/ai' }, { path: '/admin/core/debug/cache' }, diff --git a/packages/vitnode/src/api/admin-staff-permission.test.ts b/packages/vitnode/src/api/admin-staff-permission.test.ts index 7c0bcb04f1..a1ebe55902 100644 --- a/packages/vitnode/src/api/admin-staff-permission.test.ts +++ b/packages/vitnode/src/api/admin-staff-permission.test.ts @@ -7,6 +7,8 @@ import { newBuildPluginApiCore } from "./plugin"; const ALLOWLIST = [ "GET /admin/session", + // Activity Logs `can_view`, or `can_view` on the content type - see the route. + "GET /admin/activity-logs/entity", "GET /admin/staff/permission-catalog", "GET /admin/roles/list", "POST /admin/staff/entry/:type", diff --git a/packages/vitnode/src/api/lib/ai/settings-store.ts b/packages/vitnode/src/api/lib/ai/settings-store.ts index cd1d1c7c07..0ae19944c4 100644 --- a/packages/vitnode/src/api/lib/ai/settings-store.ts +++ b/packages/vitnode/src/api/lib/ai/settings-store.ts @@ -5,7 +5,7 @@ import { core_ai_settings } from "../../../database/ai"; export type AiSettingsRow = typeof core_ai_settings.$inferInsert; export const saveAiSettings = async ( - db: AiDatabase, + db: Omit, values: Omit, ): Promise => { await db diff --git a/packages/vitnode/src/api/lib/audit/access.ts b/packages/vitnode/src/api/lib/audit/access.ts new file mode 100644 index 0000000000..3d643aa5cc --- /dev/null +++ b/packages/vitnode/src/api/lib/audit/access.ts @@ -0,0 +1,26 @@ +import type { Context } from "hono"; + +import { AUDIT_CORE_PLUGIN_ID } from "../../../lib/audit/const"; +import { checkStaffPermission } from "../check-staff-permission"; + +/** The staff permission module of the Activity Logs. */ +export const AUDIT_PERMISSION_MODULE = "activity_logs"; + +export const AUDIT_PERMISSIONS = { + export: "can_export", + manageRetention: "can_manage_retention", + view: "can_view", + viewSensitive: "can_view_sensitive", +} as const; + +/** Whether the AdminCP admin holds one Activity Logs permission. */ +export const canAdminAudit = async ( + c: Context, + permission: (typeof AUDIT_PERMISSIONS)[keyof typeof AUDIT_PERMISSIONS], +): Promise => + await checkStaffPermission(c, { + module: AUDIT_PERMISSION_MODULE, + permission, + plugin: AUDIT_CORE_PLUGIN_ID, + type: "admin", + }); diff --git a/packages/vitnode/src/api/lib/audit/context.ts b/packages/vitnode/src/api/lib/audit/context.ts new file mode 100644 index 0000000000..0bed5fe5e0 --- /dev/null +++ b/packages/vitnode/src/api/lib/audit/context.ts @@ -0,0 +1,119 @@ +import type { Context } from "hono"; + +import { randomUUID } from "node:crypto"; + +import type { + AuditActorType, + AuditArea, + AuditChanges, +} 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: AuditChanges | null; + contentTypeId: null | string; + metadata: Record; + pluginId: string; + revisionId: null | number; + scope: null | { family: string; id: string }; + target: { id: null | string; label: null | string; type: string }; +} + +/** + * Trusted context layered over a block of server code with + * {@link withAuditContext}. Never built from request input. + */ +export interface AuditContextFrame { + /** + * Attributes every entry in the block to this actor instead of the request's + * user - `AUDIT_SYSTEM_ACTOR` for background work. + */ + actor?: AuditActor; + area?: AuditArea; + /** Groups the block's entries. Defaults to one id per request. */ + correlationId?: string; + /** + * Sees each entry written inside the block, before it is stored, and returns + * the entry to write - changed or not - or `null` to drop it because the + * caller records the operation as a whole. Runs inside the same transaction. + */ + intercept?: (entry: AuditPendingEntry) => AuditPendingEntry | null; + /** Merged into every entry's metadata; the entry's own keys win. */ + metadata?: Record; + /** + * The moderation scope of every entry in the block that names none - a + * report action hiding a forum post belongs to the forum's scope. + */ + scope?: null | { family: string; id: string }; +} + +const frames = new WeakMap(); +const correlationIds = new WeakMap(); + +/** + * Runs `body` with `frame` applied to every audit entry it writes on `c` - + * the trusted way to attribute background work to the system, to say which + * schedule triggered a publish, or to fold nested writes into one entry. + * Frames nest; the innermost one is applied first. Server-only. + * + * @example + * ```ts + * import { AUDIT_SYSTEM_ACTOR, withAuditContext } from "@vitnode/core/api/lib/audit/context"; + * + * await withAuditContext( + * c, + * { actor: AUDIT_SYSTEM_ACTOR, area: "system", metadata: { trigger: "import" } }, + * async () => await importProducts(c), + * ); + * ``` + */ +export const withAuditContext = async ( + c: Context, + frame: AuditContextFrame, + body: () => Promise, +): Promise => { + const stack = frames.get(c) ?? []; + stack.push(frame); + frames.set(c, stack); + + try { + return await body(); + } finally { + const index = stack.lastIndexOf(frame); + if (index !== -1) stack.splice(index, 1); + } +}; + +/** The frames active on `c`, innermost first. */ +export const auditContextFrames = (c: Context): AuditContextFrame[] => + [...(frames.get(c) ?? [])].reverse(); + +/** The id every entry written for this request (or background run) shares. */ +export const auditCorrelationId = (c: Context): string => { + for (const frame of auditContextFrames(c)) { + if (frame.correlationId) return frame.correlationId; + } + + const existing = correlationIds.get(c); + if (existing) return existing; + + const id = randomUUID(); + correlationIds.set(c, id); + + return id; +}; diff --git a/packages/vitnode/src/api/lib/audit/detail.ts b/packages/vitnode/src/api/lib/audit/detail.ts new file mode 100644 index 0000000000..06583e90a4 --- /dev/null +++ b/packages/vitnode/src/api/lib/audit/detail.ts @@ -0,0 +1,184 @@ +import type { Context } from "hono"; + +import { and, desc, eq, isNull, lt } from "drizzle-orm"; + +import type { ContentAnyRevisionSnapshot } from "@/content/revisions"; +import type { AnyContentTypeDefinition } from "@/content/types"; + +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 { AUDIT_CORE_PLUGIN_ID } from "@/lib/audit/const"; +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); + +/** A content type's definition and plugin, or `null` when it is gone or the admin may not view it. */ +export const viewableContentType = async ( + c: Context, + contentTypeId: string, +): Promise => { + const registered = findContentModel( + c.get("core").contentModels, + contentTypeId, + ); + if (!registered) return null; + const definition = contentDefinitionOf(registered.model); + const allowed = await checkStaffPermission(c, { + module: definition.permissionModule, + permission: "can_view", + plugin: registered.pluginId, + type: "admin", + }); + + return allowed ? { definition, pluginId: registered.pluginId } : null; +}; + +/** + * 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 viewable = await viewableContentType(c, entry.contentTypeId); + if (!viewable) return unavailable; + const { definition } = viewable; + + const db = c.get("db"); + const [revision] = await db + .select() + .from(core_content_revisions) + .where( + and( + eq(core_content_revisions.id, entry.revisionId), + eq(core_content_revisions.contentTypeId, definition.id), + ), + ) + .limit(1); + if (!revision) return unavailable; + + const [previous] = await db + .select({ snapshot: core_content_revisions.snapshot }) + .from(core_content_revisions) + .where( + and( + eq(core_content_revisions.contentTypeId, definition.id), + eq(core_content_revisions.itemId, revision.itemId), + revision.languageId === null + ? isNull(core_content_revisions.languageId) + : eq(core_content_revisions.languageId, revision.languageId), + lt(core_content_revisions.version, revision.version), + ), + ) + .orderBy(desc(core_content_revisions.version)) + .limit(1); + + const after = revision.snapshot.fields; + const before = (previous?.snapshot as ContentAnyRevisionSnapshot | undefined) + ?.fields; + const excluded = new Set(definition.audit.excludeFields); + const names = [ + ...new Set(revision.changedFields.map(path => path.split(".")[0])), + ].filter(name => !excluded.has(name)); + + return { + available: true, + fields: names + .filter( + name => before === undefined || !sameValue(before[name], after[name]), + ) + .map(name => ({ + // Bounded like every audit value: a rich-text document is a marker + // here, and the revision history panel is where it is read in full. + after: sanitizeAuditValue(after[name]) ?? null, + before: + before === undefined + ? null + : (sanitizeAuditValue(before[name]) ?? null), + name, + })), + operation: revision.operation, + version: revision.version, + }; +}; + +/** + * Where the target lives in the AdminCP, when the viewer may open it: the user + * or the content record. `null` for anything else - a deleted target answers 404 there + * anyway, and no plugin code is run to work a path out. + */ +const targetHrefOf = async ( + c: Context, + entry: FoundEntry, +): Promise => { + const { id, type } = entry.target; + if (id === null) return null; + + if (type === "user") { + const allowed = await checkStaffPermission(c, { + module: "users", + permission: "can_view", + plugin: AUDIT_CORE_PLUGIN_ID, + type: "admin", + }); + + return allowed ? `/admin/core/users/${id}` : null; + } + + if (entry.contentTypeId && type === entry.contentTypeId) { + const viewable = await viewableContentType(c, entry.contentTypeId); + if (!viewable) return null; + const { definition } = viewable; + const itemId = contentIdFromKey(definition.idStrategy, id); + if ( + itemId !== null && + definition.admin.edit.mode === "page" && + entry.action !== "content.deleted" + ) { + return contentEditHref(definition, itemId); + } + + return contentAdminHref(definition); + } + + return null; +}; + +/** + * One entry as the detail sheet shows it. Sensitive technical metadata (IP + * address, correlation id) only with `canViewSensitive`. + */ +export const auditDetailOf = async ( + c: Context, + entry: FoundEntry, + { canViewSensitive }: { canViewSensitive: boolean }, +): Promise => ({ + ...entry, + correlationId: canViewSensitive ? entry.correlationId : null, + ipAddress: canViewSensitive ? entry.ipAddress : null, + revision: await revisionOf(c, entry), + targetHref: await targetHrefOf(c, entry), +}); diff --git a/packages/vitnode/src/api/lib/audit/labels.ts b/packages/vitnode/src/api/lib/audit/labels.ts new file mode 100644 index 0000000000..73a5e6c23d --- /dev/null +++ b/packages/vitnode/src/api/lib/audit/labels.ts @@ -0,0 +1,102 @@ +import type { Context } from "hono"; + +import { eq } from "drizzle-orm"; + +import type { MultiLangValue } from "@/lib/helpers/multi-lang"; + +import { core_users } from "@/database/users"; + +import type { PermissionsStaffArgs } from "../permission-staff"; + +import { resolveRoleNames } from "../resolve-role-names"; + +/** + * One readable label out of a multi-language value: the default language's, + * else English, else the first one stored. `null` when there is none. + */ +export const multiLangAuditLabel = ( + values: MultiLangValue | null | undefined, + defaultLocale?: string, +): null | string => { + if (!values || values.length === 0) return null; + const pick = + values.find(entry => entry.languageCode === defaultLocale) ?? + values.find(entry => entry.languageCode === "en") ?? + values[0]; + + return pick.value.trim() === "" ? null : pick.value; +}; + +/** A role's name as an audit label, read from its stored translations. */ +export const roleAuditLabel = async ( + c: Context, + roleId: number, +): Promise => { + const names = (await resolveRoleNames(c, [roleId])).get(roleId) ?? []; + + return multiLangAuditLabel( + names.map(entry => ({ + languageCode: entry.languageCode, + value: entry.name, + })), + c.get("core")?.i18n?.defaultLocale, + ); +}; + +/** A user's display name as an audit label. */ +export const userAuditLabel = async ( + c: Context, + userId: number, +): Promise => { + const [row] = await c + .get("db") + .select({ name: core_users.name }) + .from(core_users) + .where(eq(core_users.id, userId)) + .limit(1); + + return row?.name ?? null; +}; + +/** Whom a staff permission entry governs - a role or one user - as a label. */ +export const staffEntryAuditLabel = async ( + c: Context, + entry: { + roleId: null | number | undefined; + userId: null | number | undefined; + }, +): Promise => { + if (typeof entry.roleId === "number") + return await roleAuditLabel(c, entry.roleId); + if (typeof entry.userId === "number") + return await userAuditLabel(c, entry.userId); + + return null; +}; + +/** One staff grant as a stable string: `@vitnode/core:users:can_edit[forum:1,2]`. */ +export const staffGrantAuditKey = (grant: PermissionsStaffArgs): string => { + const base = `${grant.plugin}:${grant.module}:${grant.permission}`; + if (!grant.scope) return base; + + return grant.scope.mode === "all" + ? `${base}[${grant.scope.family}:*]` + : `${base}[${grant.scope.family}:${[...grant.scope.ids].sort().join(",")}]`; +}; + +/** + * What changed between two grant lists, as audit metadata: the grants added and + * the grants removed, never the full lists. + */ +export const staffGrantsAuditDelta = ( + before: readonly PermissionsStaffArgs[], + after: readonly PermissionsStaffArgs[], +): { granted: string[]; revoked: string[] } => { + const from = new Set(before.map(staffGrantAuditKey)); + const to = new Set(after.map(staffGrantAuditKey)); + + return { + granted: [...to].filter(key => !from.has(key)).sort(), + revoked: [...from].filter(key => !to.has(key)).sort(), + }; +}; diff --git a/packages/vitnode/src/api/lib/audit/query.ts b/packages/vitnode/src/api/lib/audit/query.ts new file mode 100644 index 0000000000..90564bf522 --- /dev/null +++ b/packages/vitnode/src/api/lib/audit/query.ts @@ -0,0 +1,447 @@ +import type { SQL } from "drizzle-orm"; +import type { Context } from "hono"; + +import { z } from "@hono/zod-openapi"; +import { and, desc, eq, gte, ilike, lt, ne, or, sql } from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; + +import type { + AuditActorType, + AuditArea, + AuditChanges, +} from "@/lib/audit/const"; + +import { core_audit_logs } from "@/database/audit"; +import { core_roles } from "@/database/roles"; +import { core_users } from "@/database/users"; +import { containsLikePattern } from "@/lib/api/like-pattern"; +import { + AUDIT_ACTION_MAX_LENGTH, + AUDIT_ACTOR_TYPES, + AUDIT_AREAS, + AUDIT_PLUGIN_ID_MAX_LENGTH, + AUDIT_STAFF_LOGS, + AUDIT_TARGET_ID_MAX_LENGTH, + AUDIT_TARGET_TYPE_MAX_LENGTH, +} from "@/lib/audit/const"; + +export const AUDIT_DEFAULT_PAGE_SIZE = 25; +export const AUDIT_MAX_PAGE_SIZE = 100; +const AUDIT_SEARCH_MAX_LENGTH = 100; + +const day = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, "A date as YYYY-MM-DD.") + .openapi({ example: "2026-10-24" }); + +/** The filters every audit list understands, as query parameters. */ +export const zodAuditFilters = z.object({ + action: z.string().max(AUDIT_ACTION_MAX_LENGTH).optional(), + actorId: z.coerce.number().int().positive().optional(), + area: z.enum(AUDIT_AREAS).optional(), + /** First day included, in UTC. */ + from: day.optional(), + pluginId: z.string().max(AUDIT_PLUGIN_ID_MAX_LENGTH).optional(), + /** Matches the target's label, the action and the actor's name. */ + search: z.string().trim().max(AUDIT_SEARCH_MAX_LENGTH).optional(), + targetId: z.string().max(AUDIT_TARGET_ID_MAX_LENGTH).optional(), + targetType: z.string().max(AUDIT_TARGET_TYPE_MAX_LENGTH).optional(), + /** Last day included, in UTC. */ + to: day.optional(), + /** + * Which staff log: `moderator` keeps the moderator area, `admin` everything + * else - AdminCP, member and system activity. + */ + staff: z.enum(AUDIT_STAFF_LOGS).optional(), +}); + +export type AuditFilters = z.infer; + +export const zodAuditPageQuery = zodAuditFilters.extend({ + cursor: z.string().max(200).optional(), + first: z.coerce.number().int().min(1).max(AUDIT_MAX_PAGE_SIZE).optional(), +}); + +const zodAuditActor = z + .object({ + id: z.number(), + name: z.string(), + nameCode: z.string(), + roleColor: z.string().nullable(), + rolePrefix: z.string().nullable(), + }) + .nullable(); + +const zodAuditChanges = z + .record( + z.string(), + z.object({ + from: z.unknown().optional(), + redacted: z.literal(true).optional(), + to: z.unknown().optional(), + }), + ) + .nullable(); + +const zodAuditPageInfo = z.object({ + endCursor: z.string().nullable(), + hasNextPage: z.boolean(), +}); + +/** 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: zodAuditPageInfo, +}); + +/** One resource's history: list entries plus their recorded changes and metadata. */ +export const zodAuditEntityPage = z.object({ + edges: z.array( + zodAuditListEntry.extend({ + changes: zodAuditChanges, + metadata: z.record(z.string(), z.unknown()), + }), + ), + pageInfo: zodAuditPageInfo, +}); + +/** One entry in full, as the detail sheet shows it. */ +export const zodAuditDetail = zodAuditListEntry.extend({ + changes: zodAuditChanges, + /** Needs `can_view_sensitive`; `null` otherwise. */ + correlationId: z.string().nullable(), + /** Needs `can_view_sensitive`; `null` otherwise. */ + ipAddress: z.string().nullable(), + metadata: z.record(z.string(), z.unknown()), + /** The content revision the entry points at, when it still exists and the viewer may read it. */ + revision: z + .object({ + available: z.boolean(), + fields: z.array( + z.object({ + after: z.unknown(), + before: z.unknown(), + name: z.string(), + }), + ), + operation: z.string().nullable(), + version: z.number().nullable(), + }) + .nullable(), + /** An AdminCP path to the target, only when the viewer may open it. */ + targetHref: z.string().nullable(), +}); + +export type AuditDetail = z.infer; + +const startOfDay = (value: string): Date => new Date(`${value}T00:00:00.000Z`); + +/** The SQL of the filters, ANDed. */ +export const auditFilterConditions = (filters: AuditFilters): SQL[] => { + const conditions: (SQL | undefined)[] = [ + filters.action ? eq(core_audit_logs.action, filters.action) : undefined, + filters.actorId ? eq(core_audit_logs.actorId, filters.actorId) : undefined, + filters.area ? eq(core_audit_logs.area, filters.area) : undefined, + filters.staff === "moderator" + ? eq(core_audit_logs.area, "moderator") + : undefined, + filters.staff === "admin" + ? ne(core_audit_logs.area, "moderator") + : undefined, + filters.pluginId + ? eq(core_audit_logs.pluginId, filters.pluginId) + : undefined, + filters.targetType + ? eq(core_audit_logs.targetType, filters.targetType) + : undefined, + filters.targetId + ? eq(core_audit_logs.targetId, filters.targetId) + : undefined, + filters.from + ? gte(core_audit_logs.createdAt, startOfDay(filters.from)) + : undefined, + filters.to + ? lt( + core_audit_logs.createdAt, + new Date(startOfDay(filters.to).getTime() + 86_400_000), + ) + : undefined, + ]; + + const term = filters.search?.trim(); + if (term) { + const pattern = containsLikePattern(term); + conditions.push( + or( + ilike(core_audit_logs.targetLabel, pattern), + ilike(core_audit_logs.action, pattern), + ilike(core_audit_logs.actorName, pattern), + ), + ); + } + + return conditions.filter((condition): condition is SQL => !!condition); +}; + +const encodeCursor = (createdAt: Date, id: number): string => + Buffer.from(JSON.stringify([createdAt.toISOString(), id])).toString( + "base64url", + ); + +const decodeCursor = (cursor: string): { createdAt: Date; id: number } => { + try { + const [createdAt, id] = JSON.parse( + Buffer.from(cursor, "base64url").toString("utf8"), + ) as [unknown, unknown]; + const date = new Date(String(createdAt)); + if ( + typeof id === "number" && + Number.isSafeInteger(id) && + !Number.isNaN(date.getTime()) + ) { + return { createdAt: date, id }; + } + } catch { + // Reported below, as every other malformed cursor is. + } + + throw new HTTPException(400, { message: "Invalid cursor." }); +}; + +const listSelection = { + action: core_audit_logs.action, + actorId: core_audit_logs.actorId, + actorName: core_audit_logs.actorName, + actorType: core_audit_logs.actorType, + area: core_audit_logs.area, + contentTypeId: core_audit_logs.contentTypeId, + createdAt: core_audit_logs.createdAt, + currentName: core_users.name, + id: core_audit_logs.id, + nameCode: core_users.nameCode, + pluginId: core_audit_logs.pluginId, + revisionId: core_audit_logs.revisionId, + roleColor: core_roles.color, + rolePrefix: core_roles.prefix, + scopeFamily: core_audit_logs.scopeFamily, + scopeId: core_audit_logs.scopeId, + targetId: core_audit_logs.targetId, + targetLabel: core_audit_logs.targetLabel, + targetType: core_audit_logs.targetType, +}; + +/** A row of {@link listSelection}: the actor's account columns are `null` once it is gone. */ +interface ListRow { + action: string; + actorId: null | number; + actorName: null | string; + actorType: AuditActorType; + area: AuditArea; + contentTypeId: null | string; + createdAt: Date; + currentName: null | string; + id: number; + nameCode: null | string; + pluginId: string; + revisionId: null | number; + roleColor: null | string; + rolePrefix: null | string; + scopeFamily: null | string; + scopeId: null | string; + targetId: null | string; + targetLabel: null | string; + targetType: string; +} + +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, + area: row.area, + contentTypeId: row.contentTypeId, + createdAt: row.createdAt, + id: row.id, + 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, + }, +}); + +interface AuditPageArgs { + cursor?: string; + filters: AuditFilters; + first?: number; +} + +/** + * 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. + */ +export const listAuditLogs = async ( + c: Context, + args: AuditPageArgs, +): Promise> => { + const { edges, pageInfo } = await listAuditLogsWithDetails(c, args); + + 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, + { cursor, filters, first = AUDIT_DEFAULT_PAGE_SIZE }: AuditPageArgs, +): Promise> => { + const conditions = auditFilterConditions(filters); + if (cursor) { + const after = decodeCursor(cursor); + conditions.push( + sql`(${core_audit_logs.createdAt}, ${core_audit_logs.id}) < (${after.createdAt}, ${after.id})`, + ); + } + + const rows = await c + .get("db") + .select({ + ...listSelection, + changes: core_audit_logs.changes, + metadata: core_audit_logs.metadata, + }) + .from(core_audit_logs) + .leftJoin(core_users, eq(core_users.id, core_audit_logs.actorId)) + .leftJoin(core_roles, eq(core_roles.id, core_users.roleId)) + .where(and(...conditions)) + .orderBy(desc(core_audit_logs.createdAt), desc(core_audit_logs.id)) + .limit(first + 1); + + const page = rows.slice(0, first); + const last = page.at(-1); + + return { + edges: page.map(row => ({ + ...toListEntry(row), + changes: row.changes, + metadata: row.metadata, + })), + pageInfo: { + endCursor: last ? 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, + { filters, limit }: { 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(...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. */ +export const findAuditLog = async ( + c: Context, + 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(eq(core_audit_logs.id, id)) + .limit(1); + if (!row) return null; + + return { + ...toListEntry(row), + changes: row.changes, + correlationId: row.correlationId, + ipAddress: row.ipAddress, + metadata: row.metadata, + }; +}; diff --git a/packages/vitnode/src/api/lib/audit/registry.ts b/packages/vitnode/src/api/lib/audit/registry.ts new file mode 100644 index 0000000000..c882cfd70a --- /dev/null +++ b/packages/vitnode/src/api/lib/audit/registry.ts @@ -0,0 +1,92 @@ +import { z } from "@hono/zod-openapi"; + +import type { AuditActionDefinition } from "@/lib/audit/actions"; + +import { CONTENT_AUDIT_ACTIONS, CORE_AUDIT_ACTIONS } from "@/lib/audit/actions"; +import { AUDIT_CORE_PLUGIN_ID } from "@/lib/audit/const"; + +/** A declared audit action, with the plugin it is recorded under. */ +export interface RegisteredAuditAction extends AuditActionDefinition { + pluginId: string; +} + +/** + * Every audit action an install can record: core's own, the Content Engine's + * for each plugin that ships content types, and each plugin's declared + * `auditActions`. Read once at boot for the Activity Logs filters. + */ +export const collectAuditActions = ( + plugins: readonly { + auditActions?: readonly AuditActionDefinition[]; + contentTypes?: readonly { id: string }[]; + pluginId: string; + }[], +): RegisteredAuditAction[] => { + const actions: RegisteredAuditAction[] = CORE_AUDIT_ACTIONS.map(entry => ({ + ...entry, + pluginId: AUDIT_CORE_PLUGIN_ID, + })); + + for (const plugin of plugins) { + if ((plugin.contentTypes ?? []).length > 0) { + actions.push( + ...CONTENT_AUDIT_ACTIONS.map(action => ({ + action, + pluginId: plugin.pluginId, + targetType: "content", + })), + ); + } + for (const entry of plugin.auditActions ?? []) { + actions.push({ ...entry, pluginId: plugin.pluginId }); + } + } + + return actions; +}; + +/** What the Activity Logs filters offer: the declared actions and the installed plugins. */ +export const auditFilterOptions = (core: { + auditActions: readonly RegisteredAuditAction[]; + installedPlugins: { + plugins: readonly { icon: null | string; id: string; title: string }[]; + }; +}) => ({ + actions: core.auditActions.map(({ action, pluginId, targetType }) => ({ + action, + pluginId, + targetType, + })), + // Core is not an installed plugin, but its own entries need a filter too. + plugins: [ + ...(core.installedPlugins.plugins.some( + plugin => plugin.id === AUDIT_CORE_PLUGIN_ID, + ) + ? [] + : [{ icon: null, id: AUDIT_CORE_PLUGIN_ID, title: "VitNode" }]), + ...core.installedPlugins.plugins.map(({ icon, id, title }) => ({ + icon, + id, + title, + })), + ], +}); + +/** {@link auditFilterOptions} as an OpenAPI schema. */ +export const zodAuditFilterOptions = z.object({ + actions: z.array( + z.object({ + action: z.string(), + pluginId: z.string(), + targetType: z.string(), + }), + ), + /** Installed plugins. An entry naming any other plugin id is from a removed one. */ + plugins: z.array( + z.object({ + icon: z.string().nullable(), + id: z.string(), + title: z.string(), + }), + ), +}); diff --git a/packages/vitnode/src/api/lib/audit/retention.test.ts b/packages/vitnode/src/api/lib/audit/retention.test.ts new file mode 100644 index 0000000000..d5022d291e --- /dev/null +++ b/packages/vitnode/src/api/lib/audit/retention.test.ts @@ -0,0 +1,86 @@ +import type { SQL } from "drizzle-orm"; + +import { inArray, is, Param } from "drizzle-orm"; +import { describe, expect, it } from "vitest"; + +import { core_audit_logs } from "@/database/audit"; +import { createMemoryDb } from "@/tests/memory-db"; + +import type { AuditDatabase } from "./write"; + +import { pruneAuditLogs } from "./retention"; + +const NOW = new Date("2026-10-24T12:00:00.000Z"); +const DAY = 86_400_000; + +const entry = (id: number, ageInDays: number) => ({ + action: "role.updated", + actorType: "staff", + area: "admin", + createdAt: new Date(NOW.getTime() - ageInDays * DAY), + id, + metadata: {}, + pluginId: "@vitnode/core", + targetType: "role", +}); + +/** + * The memory db cannot evaluate a subquery, so the `id in (select ...)` of the + * retention sweep is resolved first - with the sweep's own select - and the + * delete runs against the ids it found. + */ +const harness = () => { + const memory = createMemoryDb([ + [ + core_audit_logs, + [entry(1, 400), entry(2, 366), entry(3, 364), entry(4, 1)], + ], + ]); + const db = { + delete: (table: typeof core_audit_logs) => ({ + where: (condition: SQL) => ({ + returning: async (fields: Record) => { + // Drizzle wraps a select it does not know as a bound parameter. + const subquery = condition.queryChunks + .map(chunk => (is(chunk, Param) ? chunk.value : chunk)) + .find( + (chunk): chunk is PromiseLike<{ id: number }[]> => + typeof chunk === "object" && chunk !== null && "then" in chunk, + ); + const ids = subquery ? (await subquery).map(row => row.id) : []; + + return await memory.db + .delete(table) + .where(inArray(core_audit_logs.id, ids.length > 0 ? ids : [-1])) + .returning(fields); + }, + }), + }), + select: memory.db.select, + } as unknown as AuditDatabase; + + return { + db, + remaining: () => memory.rows(core_audit_logs).map(row => row.id), + }; +}; + +describe("pruneAuditLogs", () => { + it("deletes only entries older than the retention period and counts them", async () => { + const h = harness(); + + await expect( + pruneAuditLogs(h.db, { now: NOW, retentionDays: 365 }), + ).resolves.toBe(2); + expect(h.remaining()).toEqual([3, 4]); + }); + + it("deletes nothing when retention keeps everything", async () => { + const h = harness(); + + await expect( + pruneAuditLogs(h.db, { now: NOW, retentionDays: 0 }), + ).resolves.toBe(0); + expect(h.remaining()).toEqual([1, 2, 3, 4]); + }); +}); diff --git a/packages/vitnode/src/api/lib/audit/retention.ts b/packages/vitnode/src/api/lib/audit/retention.ts new file mode 100644 index 0000000000..8ab4cbdde6 --- /dev/null +++ b/packages/vitnode/src/api/lib/audit/retention.ts @@ -0,0 +1,47 @@ +import { inArray, lt } from "drizzle-orm"; + +import { core_audit_logs } from "@/database/audit"; +import { AUDIT_RETENTION_BATCH_SIZE } from "@/lib/audit/const"; + +import type { AuditDatabase } from "./write"; + +/** Batches one retention run deletes at most, so a huge backlog spreads over several runs. */ +export const AUDIT_RETENTION_MAX_BATCHES = 100; + +/** + * Deletes entries older than `retentionDays`, oldest first, in batches of + * {@link AUDIT_RETENTION_BATCH_SIZE} - each its own statement, so the table is + * never locked for long. `0` keeps everything. Only the retention cron calls + * this; no route deletes an entry. + * + * @returns How many entries were deleted. + */ +export const pruneAuditLogs = async ( + db: AuditDatabase, + { now = new Date(), retentionDays }: { now?: Date; retentionDays: number }, +): Promise => { + if (retentionDays <= 0) return 0; + + const cutoff = new Date(now.getTime() - retentionDays * 86_400_000); + let deleted = 0; + for (let batch = 0; batch < AUDIT_RETENTION_MAX_BATCHES; batch++) { + const rows = await db + .delete(core_audit_logs) + .where( + inArray( + core_audit_logs.id, + db + .select({ id: core_audit_logs.id }) + .from(core_audit_logs) + .where(lt(core_audit_logs.createdAt, cutoff)) + .orderBy(core_audit_logs.createdAt) + .limit(AUDIT_RETENTION_BATCH_SIZE), + ), + ) + .returning({ id: core_audit_logs.id }); + deleted += rows.length; + if (rows.length < AUDIT_RETENTION_BATCH_SIZE) break; + } + + return deleted; +}; diff --git a/packages/vitnode/src/api/lib/audit/settings.ts b/packages/vitnode/src/api/lib/audit/settings.ts new file mode 100644 index 0000000000..78dcf578f8 --- /dev/null +++ b/packages/vitnode/src/api/lib/audit/settings.ts @@ -0,0 +1,34 @@ +import { eq } from "drizzle-orm"; + +import { core_audit_settings } from "@/database/audit"; +import { AUDIT_DEFAULT_RETENTION_DAYS } from "@/lib/audit/const"; + +import type { AuditDatabase } from "./write"; + +/** The Activity Logs settings, with their defaults applied. */ +export interface AuditSettings { + /** Days an entry is kept. `0` keeps everything. */ + retentionDays: number; +} + +export const loadAuditSettings = async ( + db: AuditDatabase, +): Promise => { + const [row] = await db + .select({ retentionDays: core_audit_settings.retentionDays }) + .from(core_audit_settings) + .where(eq(core_audit_settings.id, 1)) + .limit(1); + + return { retentionDays: row?.retentionDays ?? AUDIT_DEFAULT_RETENTION_DAYS }; +}; + +export const saveAuditSettings = async ( + db: AuditDatabase, + settings: AuditSettings, +): Promise => { + await db + .insert(core_audit_settings) + .values({ id: 1, ...settings }) + .onConflictDoUpdate({ set: settings, target: core_audit_settings.id }); +}; diff --git a/packages/vitnode/src/api/lib/audit/write.test.ts b/packages/vitnode/src/api/lib/audit/write.test.ts new file mode 100644 index 0000000000..a16284a6df --- /dev/null +++ b/packages/vitnode/src/api/lib/audit/write.test.ts @@ -0,0 +1,284 @@ +import type { Context } from "hono"; + +import { describe, expect, it, vi } from "vitest"; + +import { AuditModel } from "@/api/models/audit"; +import { core_audit_logs } from "@/database/audit"; +import { core_roles } from "@/database/roles"; +import { core_users } from "@/database/users"; +import { createMemoryDb } from "@/tests/memory-db"; + +import type { AuditWriteInput } from "./write"; + +import { AUDIT_SYSTEM_ACTOR, withAuditContext } from "./context"; +import { AuditLogError, writeAuditLog } from "./write"; + +const ADMIN = { id: 1, name: "Admin" }; +const MEMBER = { id: 2, name: "Member" }; +const IP = "203.0.113.7"; + +const ENTRY: AuditWriteInput = { + action: "category.updated", + target: { id: 5, label: "News", type: "category" }, +}; + +const harness = (vars: Record = {}) => { + const memory = createMemoryDb([ + [core_users, [{ ...ADMIN }, { ...MEMBER }, { id: 3, name: "Booker" }]], + [core_roles, []], + ]); + const values: Record = { + db: memory.db, + ipAddress: IP, + ...vars, + }; + const c = { get: (key: string) => values[key] } as unknown as Context; + + return { + audit: new AuditModel(c), + c, + entries: () => memory.rows(core_audit_logs), + memory, + }; +}; + +describe("writeAuditLog", () => { + it.each([ + [ + "an AdminCP admin as staff", + { admin: { user: ADMIN } }, + undefined, + { + actorId: ADMIN.id, + actorName: "Admin", + actorType: "staff", + area: "admin", + ipAddress: IP, + }, + ], + [ + "a signed-in member as api", + { user: MEMBER }, + undefined, + { actorId: MEMBER.id, actorType: "api", area: "public", ipAddress: IP }, + ], + [ + "a member in the moderator area as staff", + { user: MEMBER }, + "moderator" as const, + { actorId: MEMBER.id, actorType: "staff", area: "moderator" }, + ], + [ + "nobody as the system, without an IP address", + {}, + undefined, + { + actorId: null, + actorName: null, + actorType: "system", + area: "system", + ipAddress: null, + }, + ], + ])("attributes %s", async (_label, vars, area, expected) => { + const h = harness(vars); + + await writeAuditLog(h.c, { ...ENTRY, ...(area ? { area } : {}) }); + + expect(h.entries()).toEqual([expect.objectContaining(expected)]); + }); + + it("stores ids as text and sanitizes changes and metadata", async () => { + const h = harness({ admin: { user: ADMIN } }); + + await writeAuditLog(h.c, { + ...ENTRY, + changes: { apiKey: { from: "a", to: "b" } }, + metadata: { token: "secret", topicId: 9 }, + scope: { family: "forum", id: 4 }, + }); + + expect(h.entries()[0]).toMatchObject({ + changes: { apiKey: { redacted: true } }, + metadata: { token: "[redacted]", topicId: 9 }, + pluginId: "@vitnode/core", + scopeFamily: "forum", + scopeId: "4", + targetId: "5", + }); + }); + + it.each([ + ["an invalid action", { ...ENTRY, action: "Category Updated" }], + ["an invalid target type", { ...ENTRY, target: { type: "Category!" } }], + ])( + "throws AuditLogError for %s and writes nothing", + async (_label, input) => { + const h = harness(); + + await expect(writeAuditLog(h.c, input)).rejects.toBeInstanceOf( + AuditLogError, + ); + expect(h.entries()).toEqual([]); + }, + ); + + it("leaves no entry when the transaction it was written in rolls back", async () => { + const h = harness({ admin: { user: ADMIN } }); + + await expect( + h.memory.db.transaction(async tx => { + await writeAuditLog(h.c, ENTRY, tx as Context["var"]["db"]); + throw new Error("the change failed after the entry was written"); + }), + ).rejects.toThrow("the change failed"); + + expect(h.entries()).toEqual([]); + }); + + it("shares one correlation id across a request's entries", async () => { + const h = harness({ admin: { user: ADMIN } }); + + await writeAuditLog(h.c, ENTRY); + await writeAuditLog(h.c, { ...ENTRY, action: "category.deleted" }); + + const [first, second] = h.entries(); + expect(first.correlationId).toEqual(expect.any(String)); + expect(second.correlationId).toBe(first.correlationId); + }); +}); + +describe("withAuditContext", () => { + it("applies a frame's actor, area, scope, correlation id and metadata", async () => { + const h = harness({ admin: { user: ADMIN } }); + + await withAuditContext( + h.c, + { + actor: AUDIT_SYSTEM_ACTOR, + area: "system", + correlationId: "schedule:9", + metadata: { scheduledByUserId: 3, trigger: "frame" }, + scope: { family: "forum", id: "4" }, + }, + async () => + await writeAuditLog(h.c, { ...ENTRY, metadata: { trigger: "own" } }), + ); + + expect(h.entries()[0]).toMatchObject({ + actorId: null, + actorType: "system", + area: "system", + correlationId: "schedule:9", + ipAddress: null, + metadata: { scheduledByUserId: 3, trigger: "own" }, + scopeFamily: "forum", + scopeId: "4", + }); + }); + + it("lets an intercept rewrite an entry, or drop it with null", async () => { + const h = harness({ admin: { user: ADMIN } }); + + const results = await withAuditContext( + h.c, + { + intercept: entry => + entry.action === "category.deleted" + ? null + : { ...entry, action: "category.moved" }, + }, + async () => [ + await writeAuditLog(h.c, ENTRY), + await writeAuditLog(h.c, { ...ENTRY, action: "category.deleted" }), + ], + ); + + expect(results[1]).toBeNull(); + expect(h.entries().map(entry => entry.action)).toEqual(["category.moved"]); + }); + + it("applies nested frames innermost first", async () => { + const h = harness({ user: MEMBER }); + const seen: string[] = []; + + await withAuditContext( + h.c, + { + area: "public", + intercept: entry => { + seen.push(`outer:${entry.action}`); + + return entry; + }, + metadata: { from: "outer", outer: true }, + }, + async () => + await withAuditContext( + h.c, + { + area: "moderator", + intercept: entry => { + seen.push(`inner:${entry.action}`); + + return { ...entry, action: "report.claimed" }; + }, + metadata: { from: "inner" }, + }, + async () => await writeAuditLog(h.c, ENTRY), + ), + ); + + expect(seen).toEqual(["inner:category.updated", "outer:report.claimed"]); + expect(h.entries()[0]).toMatchObject({ + actorType: "staff", + area: "moderator", + metadata: { from: "inner", outer: true }, + }); + }); + + it("stops applying a frame once its block has finished", async () => { + const h = harness({ admin: { user: ADMIN } }); + + await withAuditContext(h.c, { area: "moderator" }, async () => + Promise.resolve(), + ); + await writeAuditLog(h.c, ENTRY); + + expect(h.entries()[0].area).toBe("admin"); + }); +}); + +describe("AuditModel.record", () => { + it("swallows a database error with bestEffort and logs only its name", async () => { + const error = vi.fn(async () => Promise.resolve()); + const h = harness({ log: { error } }); + vi.spyOn(h.memory.db, "insert").mockImplementation(() => { + throw new Error("connection refused: postgres://user:pw@db"); + }); + + await expect(h.audit.record(ENTRY, { bestEffort: true })).resolves.toBe( + null, + ); + expect(error).toHaveBeenCalledWith( + expect.not.stringContaining("postgres://"), + ); + }); + + it("throws a database error without bestEffort", async () => { + const h = harness(); + vi.spyOn(h.memory.db, "insert").mockImplementation(() => { + throw new Error("connection refused"); + }); + + await expect(h.audit.record(ENTRY)).rejects.toThrow("connection refused"); + }); + + it("never swallows an AuditLogError, even with bestEffort", async () => { + const h = harness(); + + await expect( + h.audit.record({ ...ENTRY, action: "BAD" }, { bestEffort: true }), + ).rejects.toBeInstanceOf(AuditLogError); + }); +}); diff --git a/packages/vitnode/src/api/lib/audit/write.ts b/packages/vitnode/src/api/lib/audit/write.ts new file mode 100644 index 0000000000..453cbd7963 --- /dev/null +++ b/packages/vitnode/src/api/lib/audit/write.ts @@ -0,0 +1,274 @@ +import type { Context } from "hono"; + +import { eq } from "drizzle-orm"; + +import type { + AuditArea, + AuditChanges, + AuditScope, + AuditTarget, +} from "@/lib/audit/const"; + +import { core_audit_logs } from "@/database/audit"; +import { core_users } from "@/database/users"; +import { + AUDIT_ACTION_MAX_LENGTH, + AUDIT_ACTION_PATTERN, + AUDIT_AREAS, + AUDIT_CORE_PLUGIN_ID, + AUDIT_PLUGIN_ID_MAX_LENGTH, + AUDIT_SCOPE_FAMILY_MAX_LENGTH, + AUDIT_SCOPE_ID_MAX_LENGTH, + AUDIT_TARGET_ID_MAX_LENGTH, + AUDIT_TARGET_LABEL_MAX_LENGTH, + AUDIT_TARGET_TYPE_MAX_LENGTH, + AUDIT_TARGET_TYPE_PATTERN, +} from "@/lib/audit/const"; +import { + sanitizeAuditChanges, + sanitizeAuditMetadata, +} from "@/lib/audit/sanitize"; + +import type { AuditActor, AuditPendingEntry } from "./context"; + +import { + AUDIT_SYSTEM_ACTOR, + auditContextFrames, + auditCorrelationId, +} from "./context"; + +/** A database handle or an open transaction to write the entry with. */ +export type AuditDatabase = Omit; + +/** An audit entry that cannot be stored as written - a bug in the caller. */ +export class AuditLogError extends Error { + constructor(message: string) { + super(message); + this.name = "AuditLogError"; + } +} + +/** Everything one entry can carry, before context is applied. */ +export interface AuditWriteInput { + action: string; + /** Trusted server-side attribution. Omitted, the request decides. */ + actor?: AuditActor; + area?: AuditArea; + changes?: AuditChanges | null; + contentTypeId?: null | string; + metadata?: Record; + pluginId?: string; + revisionId?: null | number; + scope?: AuditScope | null; + target: AuditTarget; +} + +const cut = (value: string, max: number): string => + value.length > max ? `${value.slice(0, max - 1)}…` : value; + +/** + * Who the request is acting as: the AdminCP admin as staff, a signed-in user + * as staff in the ModeratorCP or as `api` elsewhere, and nobody as the system. + * Only the session decides - never a value from the request body. + */ +export const resolveAuditActor = (c: Context, area: AuditArea): AuditActor => { + const adminId = c.get("admin")?.user.id; + if (typeof adminId === "number") return { type: "staff", userId: adminId }; + + const userId = c.get("user")?.id; + if (typeof userId === "number") { + return { type: area === "moderator" ? "staff" : "api", userId }; + } + + return AUDIT_SYSTEM_ACTOR; +}; + +/** The area a request writes from when nobody says otherwise. */ +export const resolveAuditArea = (c: Context): AuditArea => { + if (c.get("admin")) return "admin"; + if (c.get("user")) return "public"; + + return "system"; +}; + +const actorName = async ( + c: Context, + db: AuditDatabase, + actor: AuditActor, +): Promise => { + if (actor.userId === null) return null; + const admin = c.get("admin")?.user; + if (admin?.id === actor.userId) return admin.name; + const user = c.get("user"); + if (user?.id === actor.userId) return user.name; + + const [row] = await db + .select({ name: core_users.name }) + .from(core_users) + .where(eq(core_users.id, actor.userId)) + .limit(1); + + return row?.name ?? null; +}; + +const validate = (entry: AuditPendingEntry): void => { + if ( + entry.action.length > AUDIT_ACTION_MAX_LENGTH || + !AUDIT_ACTION_PATTERN.test(entry.action) + ) { + throw new AuditLogError( + `Audit action "${entry.action}" must be lowercase words joined by dots, like "category.updated".`, + ); + } + if ( + entry.target.type.length > AUDIT_TARGET_TYPE_MAX_LENGTH || + !AUDIT_TARGET_TYPE_PATTERN.test(entry.target.type) + ) { + throw new AuditLogError( + `Audit target type "${entry.target.type}" must be a short lowercase identifier, like "category".`, + ); + } + if ( + entry.pluginId === "" || + entry.pluginId.length > AUDIT_PLUGIN_ID_MAX_LENGTH + ) { + throw new AuditLogError( + "An audit entry needs the id of the plugin it belongs to.", + ); + } + if (!(AUDIT_AREAS as readonly string[]).includes(entry.area)) { + throw new AuditLogError(`Unknown audit area "${entry.area}".`); + } +}; + +/** + * Resolves context, applies the active {@link withAuditContext} frames, + * sanitizes and writes one entry. Inside `tx` the entry commits or rolls back + * with the caller's change; a failed insert throws, so a critical operation + * cannot commit without its entry. + * + * Internal to core: plugins call `c.get("audit").record()`. + * + * @returns The new entry's id, or `null` when a frame dropped it. + */ +export const writeAuditLog = async ( + c: Context, + input: AuditWriteInput, + tx?: AuditDatabase, +): Promise => { + const db = tx ?? c.get("db"); + const frames = auditContextFrames(c); + const framed = (key: TKey) => + frames.find(frame => frame[key] !== undefined)?.[key]; + + const area = input.area ?? framed("area") ?? resolveAuditArea(c); + const resolved = input.actor ?? framed("actor") ?? resolveAuditActor(c, area); + // A member acting from a moderator tool is acting as staff, whatever the + // write path called them. + const actor: AuditActor = + area === "moderator" && resolved.type === "api" + ? { ...resolved, type: "staff" } + : resolved; + const frameMetadata = Object.assign( + {}, + ...[...frames].reverse().map(frame => frame.metadata ?? {}), + ) as Record; + + let entry: AuditPendingEntry | null = { + action: input.action, + actor, + area, + changes: input.changes ?? null, + contentTypeId: input.contentTypeId ?? null, + metadata: { ...frameMetadata, ...input.metadata }, + pluginId: input.pluginId ?? c.get("plugin")?.id ?? AUDIT_CORE_PLUGIN_ID, + revisionId: input.revisionId ?? null, + scope: input.scope + ? { family: input.scope.family, id: String(input.scope.id) } + : (frames.find(frame => frame.scope)?.scope ?? null), + target: { + id: + input.target.id === undefined || input.target.id === null + ? null + : String(input.target.id), + label: input.target.label ?? null, + type: input.target.type, + }, + }; + + for (const frame of frames) { + if (!frame.intercept) continue; + entry = frame.intercept(entry); + if (!entry) return null; + } + + validate(entry); + + const [row] = await db + .insert(core_audit_logs) + .values({ + action: entry.action, + actorId: entry.actor.userId, + actorName: await actorName(c, db, entry.actor), + actorType: entry.actor.type, + area: entry.area, + changes: sanitizeAuditChanges(entry.changes), + contentTypeId: entry.contentTypeId, + correlationId: auditCorrelationId(c), + // The address of whoever acted. A system entry has nobody behind it, and + // the address of the cron caller would only be noise with a privacy cost. + ipAddress: + entry.actor.type === "system" ? null : (c.get("ipAddress") ?? null), + metadata: sanitizeAuditMetadata(entry.metadata), + pluginId: entry.pluginId, + revisionId: entry.revisionId, + scopeFamily: entry.scope + ? cut(entry.scope.family, AUDIT_SCOPE_FAMILY_MAX_LENGTH) + : null, + scopeId: entry.scope + ? cut(entry.scope.id, AUDIT_SCOPE_ID_MAX_LENGTH) + : null, + targetId: + entry.target.id === null + ? null + : cut(entry.target.id, AUDIT_TARGET_ID_MAX_LENGTH), + targetLabel: + entry.target.label === null + ? null + : cut(entry.target.label, AUDIT_TARGET_LABEL_MAX_LENGTH), + targetType: entry.target.type, + }) + .returning({ id: core_audit_logs.id }); + + return row?.id ?? null; +}; + +/** + * {@link writeAuditLog} for an operation that has already happened outside a + * transaction this code owns (sessions revoked through another model, a + * setting saved by a helper): a failed write is reported to the technical log + * - without the payload - instead of failing a request whose change stands. + */ +export const writeAuditLogBestEffort = async ( + c: Context, + input: AuditWriteInput, +): Promise => { + try { + return await writeAuditLog(c, input); + } catch (error) { + if (error instanceof AuditLogError) throw error; + try { + await c + .get("log") + ?.error( + `[audit] Could not record "${input.action}" on ${input.target.type}: ${ + error instanceof Error ? error.name : "unknown error" + }`, + ); + } catch { + // The logger writes to the same database; nothing more to do. + } + + return null; + } +}; diff --git a/packages/vitnode/src/api/lib/plugin.ts b/packages/vitnode/src/api/lib/plugin.ts index 91f677fc3a..cdf98637b2 100644 --- a/packages/vitnode/src/api/lib/plugin.ts +++ b/packages/vitnode/src/api/lib/plugin.ts @@ -7,6 +7,7 @@ import type { RegisteredContentType } from "@/content/registry"; import type { AnyContentModel } from "@/content/server/model"; import type { ContentNotificationsConfig } from "@/content/server/notifications/define"; import type { AnyContentTypeDefinition } from "@/content/types"; +import type { AuditActionDefinition } from "@/lib/audit/actions"; import type { ApiPluginContract } from "@/lib/fetcher/contract"; import type { LocaleMessagesMap } from "@/lib/i18n/types"; import type { PluginManifest } from "@/lib/plugin-manifest"; @@ -66,6 +67,8 @@ export interface BuildPluginApiReturn< readonly BaseBuildModuleReturn